/* ----------------------------------------------------------------------------- Copyright (c) 2025 [Christian X Bridges]. All rights reserved. This code is proprietary and confidential. Unauthorized copying, distribution, modification, or use of this file, via any medium, is strictly prohibited without prior written permission from the copyright holder. ----------------------------------------------------------------------------- */ // Minimalist Bible App - Reconstructed app.js // The version lives in package.json ONLY. About reads it from the running app, so there is no // hand-typed build number here to go stale. console.log('[APP] app.js module loaded'); // Supports: tabbed navigation, book/chapter/verse selection, multiple Bible API passage loading, verse numbers with brackets // Import word data functions import { getWordInfo, hasWordData, getLocalDatabaseWordCount, getLocalDatabaseWords } from './word-data.js'; import { enhanceWordInfoWithAccordance, checkAccordanceAvailability, queryAccordanceWord } from './accordance-api.js'; import { enhanceWordInfoWithRapidAPI, checkRapidAPIAvailability, queryRapidAPIWord, parseRapidAPIResponse } from './rapidapi-study-bible.js'; import LocalMediaHandler from './local-media-handler.js'; // Load bibleStructure from bible-structure.js import { bibleStructure } from './bible-structure.js'; // Load NLT data for instant, reliable NLT text import { getNLTText } from './nlt-data.js'; // Make word data functions globally available window.getWordInfo = getWordInfo; window.hasWordData = hasWordData; window.getLocalDatabaseWordCount = getLocalDatabaseWordCount; window.getLocalDatabaseWords = getLocalDatabaseWords; // Helper function to count clickable words on current page window.countClickableWords = function() { const activeTabId = window.bibleApp?.activeTabId; if (!activeTabId) { console.log('No active tab'); return { local: 0, onPage: 0, total: 0 }; } const bibleText = document.getElementById(`bibleText${activeTabId}`); if (!bibleText) { console.log('Bible text element not found'); return { local: 0, onPage: 0, total: 0 }; } const clickableWords = bibleText.querySelectorAll('.clickable-word.has-data'); const allWords = bibleText.querySelectorAll('.clickable-word'); const localDbCount = getLocalDatabaseWordCount(); const localDbWords = getLocalDatabaseWords(); console.log('=== CLICKABLE WORDS REPORT ==='); console.log(`Local database words: ${localDbCount}`); console.log(`Local database words list:`, localDbWords); console.log(`Words on current page: ${allWords.length}`); console.log(`Clickable words on current page (with data): ${clickableWords.length}`); console.log(`Words without data on page: ${allWords.length - clickableWords.length}`); return { local: localDbCount, localWords: localDbWords, onPage: clickableWords.length, totalOnPage: allWords.length, withoutData: allWords.length - clickableWords.length }; }; // Function to test RapidAPI for multiple words and see how many have Greek definitions window.testRapidAPIForWords = async function(wordList = null, maxWords = 50) { console.log('=== TESTING RAPIDAPI FOR WORDS ==='); // Get words from current page if not provided if (!wordList) { const activeTabId = window.bibleApp?.activeTabId; if (!activeTabId) { console.log('?? No active tab found. Loading a Bible chapter first...'); console.log('?? Tip: Load a Bible chapter, or test specific words like:'); console.log(' testRapidAPIForWords(["love", "grace", "faith", "hope", "peace"])'); return { tested: 0, withGreek: 0, withoutGreek: 0, results: [], error: 'No active tab' }; } const bibleText = document.getElementById(`bibleText${activeTabId}`); if (!bibleText) { console.log('?? Bible text element not found. Make sure a Bible chapter is loaded.'); console.log('?? Tip: Load a Bible chapter, or test specific words like:'); console.log(' testRapidAPIForWords(["love", "grace", "faith", "hope", "peace"])'); return { tested: 0, withGreek: 0, withoutGreek: 0, results: [], error: 'Bible text not found' }; } // Get unique words from page const wordSpans = bibleText.querySelectorAll('.clickable-word'); console.log(`Found ${wordSpans.length} clickable word spans on page`); const uniqueWords = new Set(); wordSpans.forEach(span => { const word = (span.getAttribute('data-word-lc') || span.textContent || '').toLowerCase().trim().replace(/[^\w]/g, ''); if (word && word.length > 2) { uniqueWords.add(word); } }); if (uniqueWords.size === 0) { console.log('?? No words found on page. The Bible text may not be loaded yet.'); console.log('?? Tip: Wait for the chapter to load, or test specific words like:'); console.log(' testRapidAPIForWords(["love", "grace", "faith", "hope", "peace"])'); return { tested: 0, withGreek: 0, withoutGreek: 0, results: [], error: 'No words found on page' }; } wordList = Array.from(uniqueWords).slice(0, maxWords); console.log(`Found ${uniqueWords.size} unique words, testing first ${wordList.length}`); } if (!wordList || wordList.length === 0) { console.log('?? No words to test'); return { tested: 0, withGreek: 0, withoutGreek: 0, results: [], error: 'No words provided' }; } console.log(`Testing ${wordList.length} words with RapidAPI...`); console.log('Words to test:', wordList); const results = []; let withGreek = 0; let withoutGreek = 0; // Test each word for (let i = 0; i < wordList.length; i++) { const word = wordList[i]; console.log(`[${i + 1}/${wordList.length}] Testing: "${word}"`); try { const rapidAPIData = await window.queryRapidAPIWord(word, null); if (rapidAPIData.success && rapidAPIData.data) { const parsed = window.parseRapidAPIResponse ? window.parseRapidAPIResponse(rapidAPIData, word) : null; const hasGreek = parsed && parsed.greek && parsed.greek.text && parsed.greek.text !== '�' && parsed.greek.text.trim().length > 0; if (hasGreek) { withGreek++; console.log(` ? "${word}" HAS Greek definition: ${parsed.greek.text}`); } else { withoutGreek++; console.log(` ? "${word}" NO Greek definition`); } results.push({ word: word, hasGreek: hasGreek, greek: parsed?.greek?.text || null, definition: parsed?.definition || null, strongs: parsed?.strongs || null }); } else { withoutGreek++; console.log(` ? "${word}" API call failed: ${rapidAPIData.error || 'Unknown error'}`); results.push({ word: word, hasGreek: false, error: rapidAPIData.error || 'Unknown error' }); } // Small delay to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { withoutGreek++; console.error(` ? Error testing "${word}":`, error); results.push({ word: word, hasGreek: false, error: error.message }); } } console.log('\n=== RAPIDAPI TEST RESULTS ==='); console.log(`Total words tested: ${wordList.length}`); console.log(`Words WITH Greek definitions: ${withGreek}`); console.log(`Words WITHOUT Greek definitions: ${withoutGreek}`); console.log(`Success rate: ${((withGreek / wordList.length) * 100).toFixed(1)}%`); console.log('\nDetailed results:', results); return { tested: wordList.length, withGreek: withGreek, withoutGreek: withoutGreek, successRate: (withGreek / wordList.length) * 100, results: results }; }; // Make parseRapidAPIResponse available globally for testing window.parseRapidAPIResponse = null; // Will be set from rapidapi-study-bible.js // Auto-run test when page loads (if RapidAPI is configured) window.autoTestRapidAPI = async function() { console.log('?? AUTO-TESTING RAPIDAPI...'); // Check if RapidAPI is configured const apiKey = localStorage.getItem('rapidapi.key'); if (!apiKey) { console.log('?? RapidAPI not configured. Set your API key first:'); console.log(' localStorage.setItem("rapidapi.key", "your-key-here")'); console.log(' localStorage.setItem("rapidapi.host", "complete-study-bible.p.rapidapi.com")'); return; } // Test key theological and significant Bible words const keyWords = [ // Core theological concepts "atonement", "redemption", "salvation", "justification", "sanctification", "propitiation", "reconciliation", "regeneration", "glorification", // Important Greek concepts "agape", "charis", "pistis", "dikaiosyne", "eleos", "sophia", "metanoia", "parakletos", "ekklesia", "euangelion", "martyr", // Key Hebrew concepts "covenant", "testament", "messiah", "prophet", "priest", "king", "temple", "sacrifice", "offering", "altar", "sanctuary", // Significant words "resurrection", "ascension", "incarnation", "trinity", "divinity", "humanity", "hypostatic", "kenosis", "theosis", "eschatology", // Important actions/verbs "believe", "repent", "baptize", "worship", "pray", "praise", "bless", "curse", "judge", "forgive", "redeem", "save", // Key nouns "gospel", "scripture", "law", "commandment", "testimony", "witness", "apostle", "disciple", "saint", "angel", "demon", "satan" ]; console.log(`Testing ${keyWords.length} key theological and significant Bible words...`); const result = await testRapidAPIForWords(keyWords); console.log('\n?? FINAL SUMMARY:'); console.log(`? Words WITH Greek definitions: ${result.withGreek}`); console.log(`? Words WITHOUT Greek definitions: ${result.withoutGreek}`); console.log(`?? Success rate: ${result.successRate.toFixed(1)}%`); console.log(`\n?? With RapidAPI, you can potentially have ${result.withGreek}+ KEY words clickable!`); console.log(` (Plus ${getLocalDatabaseWordCount()} words from local database = ${result.withGreek + getLocalDatabaseWordCount()} total)`); // Show which key words have Greek definitions const wordsWithGreek = result.results.filter(r => r.hasGreek).map(r => r.word); if (wordsWithGreek.length > 0) { console.log(`\n? Key words WITH Greek definitions (${wordsWithGreek.length}):`); console.log(wordsWithGreek.join(', ')); } const wordsWithoutGreek = result.results.filter(r => !r.hasGreek).map(r => r.word); if (wordsWithoutGreek.length > 0) { console.log(`\n? Key words WITHOUT Greek definitions (${wordsWithoutGreek.length}):`); console.log(wordsWithoutGreek.join(', ')); } return result; }; // Run auto-test after a short delay (give page time to load) setTimeout(() => { if (typeof window.autoTestRapidAPI === 'function') { console.log('?? To auto-test RapidAPI, run: autoTestRapidAPI()'); } }, 2000); // Make Accordance functions globally available window.checkAccordanceAvailability = checkAccordanceAvailability; window.enhanceWordInfoWithAccordance = enhanceWordInfoWithAccordance; window.queryAccordanceWord = queryAccordanceWord; // Make RapidAPI functions globally available window.checkRapidAPIAvailability = checkRapidAPIAvailability; window.enhanceWordInfoWithRapidAPI = enhanceWordInfoWithRapidAPI; window.queryRapidAPIWord = queryRapidAPIWord; window.parseRapidAPIResponse = parseRapidAPIResponse; // Test function for RapidAPI Complete Study Bible integration - can be called from console window.testRapidAPIIntegration = async function(word = 'love') { console.log('?? TESTING RapidAPI Integration for word:', word); try { // Test 1: Check availability console.log('?? Test 1: Checking RapidAPI availability...'); const available = await checkRapidAPIAvailability(); console.log('?? RapidAPI available:', available); // Test 2: Query RapidAPI console.log('?? Test 2: Querying RapidAPI for word:', word); const rapidAPIData = await window.queryRapidAPIWord(word, 'John 3:16'); console.log('?? RapidAPI response:', rapidAPIData); // Test 3: Enhance word info console.log('?? Test 3: Enhancing word info...'); const baseWordInfo = { word: word, greek: { text: "�", transliteration: "test", meaning: "test meaning" }, hebrew: { text: "�", transliteration: "test", meaning: "test meaning" }, frequency: 0, found: false }; const enhanced = await enhanceWordInfoWithRapidAPI(baseWordInfo, word, 'John 3:16'); console.log('?? Enhanced word info:', enhanced); console.log('?? RapidAPI section:', enhanced.rapidAPI); // Test 4: Create popup console.log('?? Test 4: Creating test popup...'); const testSpan = document.createElement('span'); testSpan.textContent = word; testSpan.className = 'clickable-word'; // Find reading area to append popup to const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); let readingArea = null; if (activeTab) { readingArea = document.getElementById(`readingArea${activeTab.id}`); } if (!readingArea) { readingArea = document.querySelector('.reading-area') || document.body; } const testEvent = { target: testSpan, clientX: 100, clientY: 100 }; // Mock closest method for popup positioning testSpan.closest = () => readingArea; showWordPopup(enhanced, testEvent); console.log('?? Test popup created! Check for RapidAPI section in the popup.'); return { available, rapidAPIData, enhanced }; } catch (error) { console.error('?? TEST ERROR:', error); console.error('?? Error stack:', error.stack); return { error: error.message }; } }; // Test function for Accordance integration - can be called from console window.testAccordanceIntegration = async function(word = 'love') { console.log('?? TESTING Accordance Integration for word:', word); try { // Test 1: Check availability console.log('?? Test 1: Checking Accordance availability...'); const available = await checkAccordanceAvailability(); console.log('?? Accordance available:', available); // Test 2: Query Accordance console.log('?? Test 2: Querying Accordance for word:', word); const accordanceData = await queryAccordanceWord(word, 'John 3:16'); console.log('?? Accordance response:', accordanceData); // Test 3: Enhance word info console.log('?? Test 3: Enhancing word info...'); const baseWordInfo = { word: word, greek: { text: "�", transliteration: "test", meaning: "test meaning" }, hebrew: { text: "�", transliteration: "test", meaning: "test meaning" }, frequency: 0, found: false }; const enhanced = await enhanceWordInfoWithAccordance(baseWordInfo, word, 'John 3:16'); console.log('?? Enhanced word info:', enhanced); console.log('?? Accordance section:', enhanced.accordance); // Test 4: Create popup console.log('?? Test 4: Creating test popup...'); const testSpan = document.createElement('span'); testSpan.textContent = word; testSpan.className = 'clickable-word'; // Find reading area to append popup to const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); let readingArea = null; if (activeTab) { readingArea = document.getElementById(`readingArea${activeTab.id}`); } if (!readingArea) { readingArea = document.querySelector('.reading-area') || document.body; } const testEvent = { target: testSpan, clientX: 100, clientY: 100 }; // Mock closest method for popup positioning testSpan.closest = () => readingArea; showWordPopup(enhanced, testEvent); console.log('?? Test popup created! Check for Accordance section in the popup.'); return { available, accordanceData, enhanced }; } catch (error) { console.error('?? TEST ERROR:', error); console.error('?? Error stack:', error.stack); return { error: error.message }; } }; // Canon groupings for filtering books const OLD_TESTAMENT_BOOKS = [ 'Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth', '1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah', 'Esther','Job','Psalms','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah', 'Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum', 'Habakkuk','Zephaniah','Haggai','Zechariah','Malachi' ]; const NEW_TESTAMENT_BOOKS = [ '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' ]; function getActiveTabId() { const activePanel = document.querySelector('.tab-panel.active'); return activePanel ? activePanel.dataset.tabId : null; } function getTabById(tabId) { if (!tabId || !window.bibleApp || !Array.isArray(window.bibleApp.tabs)) return null; return window.bibleApp.tabs.find(t => String(t.id) === String(tabId)); } function getActiveTab() { const tabId = getActiveTabId(); return getTabById(tabId); } function getAnnotationStorageKey(tabId) { // Use tab ID for unique key - each tab has its own annotation space return `bible-annotations-tab-${tabId}`; } /* * The ProPresenter Sync tab is a real Bible tab (mode 'bible' + ppsync), so by default it lands on the * SAME chapter key as a Bible tab showing that chapter — one chapter, one set of marks, wherever it is open. * * Settings > ProPresenter Sync > "Separate annotations" gives the sync tab its own store instead, so the * same book and chapter can be open in both with different marks on each (teach on one, follow the slides * on the other). Only the key changes: everything downstream - drawing, undo/redo, clear, the follower * screen, the phone remote - keys off whatever this returns and needs no change. * * Flipping the switch never destroys anything; each side's marks stay under their own key and come back * when it is flipped again. */ const PPSYNC_SEPARATE_INK_KEY = 'ppsyncSeparateAnnotations'; function ppsyncSeparateInk() { try { return localStorage.getItem(PPSYNC_SEPARATE_INK_KEY) === '1'; } catch (_) { return false; } } window.ppsyncSeparateInk = ppsyncSeparateInk; function getPassageKey(book, chapter, verse, verseEnd, translation, mode = 'bible', ppsync = false) { if (mode === 'media') { // For media tabs, use tab-specific storage since they don't have passages const tabId = getActiveTabId(); return `media-annotations-${tabId}`; } if (mode === 'whiteboard') { const tabId = getActiveTabId(); return `whiteboard-annotations-${tabId}`; } if (mode === 'announcements') { const tabId = getActiveTabId(); return tabId ? `announcements-annotations-${tabId}` : 'announcements-annotations'; } if (mode === 'graphics') { const tabId = getActiveTabId(); return tabId ? `graphics-annotations-${tabId}` : 'graphics-annotations'; } if (mode === 'maps') { const tabId = getActiveTabId(); return tabId ? `maps-annotations-${tabId}` : 'maps-annotations'; } if (mode === 'notes') { const tabId = getActiveTabId(); return `notes-annotations-${tabId}`; } // CRITICAL FIX: Annotations are now chapter-based, per translation // Format: bible-annotations-${book}-${chapter}-${translation} // No verse/verseEnd - entire chapter annotations only // Each translation has separate annotations for the same chapter if (mode === 'alpha') { // Alpha tabs use chapter-based format return `bible-annotations-${book}-${chapter}-${translation}`; } // The sync tab, when it has been given a store of its own (Settings > ProPresenter Sync). if (ppsync && ppsyncSeparateInk()) { return `ppsync-annotations-${book}-${chapter}-${translation}`; } return `bible-annotations-${book}-${chapter}-${translation}`; } let drawing = false; let drawingTabId = null; let lastPoint = null; let highlighterPoints = []; let highlighterOverlayCanvas = null; let highlighterSnapshot = null; let eraserVisualAid = null; let isErasing = false; let eraserStartState = null; // Store the state before erasing starts let penColor = '#ffff00'; let highlighterColor = '#ffff00'; const DEFAULT_ENABLED_TRANSLATIONS = ['ESV', 'NIV', 'NLT', 'KJV', 'NKJV', 'MSG', 'AMP', 'NASB']; const TRANSLATION_CATALOG = [ { code: 'ESV', label: 'ESV', description: 'English Standard Version' }, { code: 'NIV', label: 'NIV', description: 'New International Version' }, { code: 'NLT', label: 'NLT', description: 'New Living Translation' }, { code: 'KJV', label: 'KJV', description: 'King James Version' }, { code: 'NKJV', label: 'NKJV', description: 'New King James Version' }, { code: 'MSG', label: 'MSG', description: 'The Message' }, { code: 'AMP', label: 'AMP', description: 'Amplified Bible' }, { code: 'NASB', label: 'NASB', description: 'New American Standard Bible' }, { code: 'ASV', label: 'ASV', description: 'American Standard Version' }, { code: 'AUV', label: 'AUV', description: 'An Understandable Version' }, { code: 'BSB', label: 'BSB', description: 'Berean Standard Bible' }, { code: 'CEB', label: 'CEB', description: 'Common English Bible' }, { code: 'CEVD', label: 'CEVD', description: 'Contemporary English Version' }, { code: 'CJB', label: 'CJB', description: 'Complete Jewish Bible' }, { code: 'CSB17', label: 'CSB17', description: 'Christian Standard Bible 2017' }, { code: 'DRB', label: 'DRB', description: 'Douay-Rheims Bible' }, { code: 'ERV', label: 'ERV', description: 'Easy-to-Read Version' }, { code: 'GNT', label: 'GNT', description: 'Good News Translation' }, { code: 'GNTD', label: 'GNTD', description: 'Good News Translation with Deuterocanon' }, { code: 'GNV', label: 'GNV', description: 'Geneva Bible' }, { code: 'ISV', label: 'ISV', description: 'International Standard Version' }, { code: 'LBP', label: 'LBP', description: 'The Living Bible Paraphrased' }, { code: 'LSB', label: 'LSB', description: 'Legacy Standard Bible' }, { code: 'LSV', label: 'LSV', description: 'Literal Standard Version' }, { code: 'LXXE', label: 'LXXE', description: 'Brenton English Septuagint' }, { code: 'MEV', label: 'MEV', description: 'Modern English Version' }, { code: 'NABRE', label: 'NABRE', description: 'New American Bible Revised Edition' }, { code: 'NET', label: 'NET', description: 'New English Translation' }, { code: 'NIV2011', label: 'NIV2011', description: 'New International Version 2011' }, { code: 'NJB1985', label: 'NJB1985', description: 'New Jerusalem Bible 1985' }, { code: 'NLV', label: 'NLV', description: 'New Life Version' }, { code: 'NRSVCE', label: 'NRSVCE', description: 'New Revised Standard Version Catholic Edition' }, { code: 'RSV', label: 'RSV', description: 'Revised Standard Version' }, { code: 'RSV2CE', label: 'RSV2CE', description: 'Revised Standard Version, Second Catholic Edition' }, { code: 'SPE', label: 'SPE', description: 'Samaritan Pentateuch in English' }, { code: 'TLV', label: 'TLV', description: 'Tree of Life Version' }, { code: 'TS2009', label: 'TS2009', description: 'The Scriptures 2009' }, { code: 'WEB', label: 'WEB', description: 'World English Bible' }, { code: 'YLT', label: 'YLT', description: "Young's Literal Translation" } ]; function getDefaultTranslationVisibility() { const enabled = new Set(DEFAULT_ENABLED_TRANSLATIONS); const visibility = {}; TRANSLATION_CATALOG.forEach(t => { visibility[t.code] = enabled.has(t.code); }); return visibility; } function getTranslationVisibilitySettings() { const defaults = getDefaultTranslationVisibility(); try { const saved = localStorage.getItem('translationVisibility'); if (saved) { const parsed = JSON.parse(saved); if (parsed && typeof parsed === 'object') { return { ...defaults, ...parsed }; } } } catch (e) { console.error('Failed to load translation visibility settings:', e); } return defaults; } function getVisibleTranslationCodes() { const visibility = getTranslationVisibilitySettings(); return TRANSLATION_CATALOG .filter(t => visibility[t.code] === true) .map(t => t.code); } function getVisibleTranslationCodesForTab(tab = null) { const codes = getVisibleTranslationCodes(); const current = tab && tab.translation ? String(tab.translation).toUpperCase() : ''; // If a tab is already using a translation that was later toggled off, keep it // available in that tab's menus so the UI never loses the selected state. if (current && !codes.includes(current)) { codes.unshift(current); } return codes; } // Curated pen sizes. Fewer, well-spaced notches make every option easy to hit // on a touchscreen, and getting back to the default (4) is just a tap on the // second notch from the left — no more "drag forever to find the right size". const PEN_SIZE_NOTCHES = [2, 4, 6, 10, 16, 24, 40]; const DEFAULT_PEN_SIZE = 4; const DEFAULT_PEN_NOTCH_INDEX = PEN_SIZE_NOTCHES.indexOf(DEFAULT_PEN_SIZE); const PEN_SIZE_MIN = PEN_SIZE_NOTCHES[0]; const PEN_SIZE_MAX = PEN_SIZE_NOTCHES[PEN_SIZE_NOTCHES.length - 1]; const PEN_SIZE_SLIDER_THUMB_SIZE = 20; let penSize = DEFAULT_PEN_SIZE; function snapPenSizeToNotch(size) { let nearest = PEN_SIZE_NOTCHES[0]; let minDistance = Math.abs(size - nearest); for (let i = 1; i < PEN_SIZE_NOTCHES.length; i++) { const distance = Math.abs(size - PEN_SIZE_NOTCHES[i]); if (distance < minDistance) { minDistance = distance; nearest = PEN_SIZE_NOTCHES[i]; } } return nearest; } // Convert an arbitrary size to the slider's indexed value (0..N-1). function penSizeToNotchIndex(size) { const snapped = snapPenSizeToNotch(size); const idx = PEN_SIZE_NOTCHES.indexOf(snapped); return idx >= 0 ? idx : DEFAULT_PEN_NOTCH_INDEX; } // Convert a slider index back to its canonical notch size (clamped). function notchIndexToPenSize(index) { if (!Number.isFinite(index)) return DEFAULT_PEN_SIZE; const clamped = Math.max(0, Math.min(PEN_SIZE_NOTCHES.length - 1, Math.round(index))); return PEN_SIZE_NOTCHES[clamped]; } // Central setter so every code path — undo/redo, slider drag, slider input, // reset button, OSC — funnels through one place that: // 1. snaps to a real notch (no half-notch sizes ever) // 2. updates the slider's indexed value // 3. keeps window.penSize in sync for legacy globals // Returns the actual size that was set (after snap). function setPenSize(size, opts = {}) { const snapped = snapPenSizeToNotch(Number(size) || DEFAULT_PEN_SIZE); const changed = snapped !== penSize; penSize = snapped; try { window.penSize = penSize; } catch (_) {} const slider = window.penSizeSlider || (typeof penSizeSlider !== 'undefined' ? penSizeSlider : null); if (slider) { const idx = penSizeToNotchIndex(snapped); if (Number(slider.value) !== idx) slider.value = idx; } return { size: snapped, changed }; } function renderPenSizeScaleBackground() { const slider = window.penSizeSlider; if (!slider) return; const sliderWidth = slider.clientWidth || 0; if (sliderWidth <= 0) return; // Keep the slider track itself plain (just the 8px dark bar) — actual tick // marks are rendered as DOM elements in the overlay so they can extend // above and below the track without being clipped by the input's height. slider.style.background = '#555'; const ticks = document.getElementById('penSizeTicks'); if (!ticks) return; const thumbInset = PEN_SIZE_SLIDER_THUMB_SIZE / 2; const travelWidth = Math.max(0, sliderWidth - PEN_SIZE_SLIDER_THUMB_SIZE); const lastIndex = PEN_SIZE_NOTCHES.length - 1; // Match the overlay's horizontal extent to the slider's so tick x-coordinates // line up exactly with the slider thumb's possible positions. ticks.style.left = '0px'; ticks.style.right = '0px'; // Build all tick elements in one pass to avoid layout thrash mid-loop. const html = []; for (let i = 0; i < PEN_SIZE_NOTCHES.length; i++) { const ratio = lastIndex === 0 ? 0 : (i / lastIndex); const x = thumbInset + (ratio * travelWidth); const notch = PEN_SIZE_NOTCHES[i]; const isEnd = (i === 0 || i === lastIndex); const isDefault = notch === DEFAULT_PEN_SIZE; const cls = ['size-slider-tick']; if (isDefault) cls.push('is-default'); else if (isEnd) cls.push('is-end'); const title = isDefault ? `${notch}px (default)` : `${notch}px`; html.push(``); } ticks.innerHTML = html.join(''); } // Preserve prior pen color when forcing black for Notes tab let __prevPenColorBeforeNotes = null; // Preserve prior pen color when forcing white for Whiteboard tab let __prevPenColorBeforeWhiteboard = null; // Announcements carousel state const ANNOUNCEMENT_CACHE_TTL_MS = 15 * 60 * 1000; let announcementsCache = { ts: 0, items: [] }; let announcementStylesInjected = false; function getSavedAnnouncementIndex(tabId) { try { const raw = localStorage.getItem(`announcement-index-${tabId}`); const val = Number(raw); return Number.isFinite(val) && val >= 0 ? val : 0; } catch (_) { return 0; } } function saveAnnouncementIndex(tabId, index) { try { localStorage.setItem(`announcement-index-${tabId}`, String(index)); } catch (_) {} } /* * ---- Transparent-artwork backdrop ---- * Vector art exported from tracing tools is typically BLACK paint on a TRANSPARENT background. On * this app's black media cards that renders 100% invisible — the file is fine, there is simply * nothing to see. So measure the decoded image ONCE and, if it is mostly transparent with dark paint, * sit it on white "paper" the way a printed map would be. Light or colourful art is left alone, and * fully opaque photos never match, so nothing that already works changes. * * MODULE SCOPE ON PURPOSE. This used to be a const inside renderTabContent's graphics/maps branch * while the ANNOUNCEMENTS branch called it too — a sibling block, so the name was not in scope there. * The announcements carousel therefore threw ReferenceError the moment an image was already cached * (the synchronous path), which took out the whole slide list and left the tab blank on every visit * after the first. Keep exactly one definition, reachable from both branches. */ // ---- Transparent-artwork backdrop ---- // Vector art exported from tracing tools is typically BLACK paint on a TRANSPARENT // background. On this app's black media cards that renders 100% invisible — the file is // fine, there is simply nothing to see. So measure the decoded image ONCE and, if it is // mostly transparent with dark paint, sit it on white "paper" the way a printed map // would be. Light or colourful art is left alone, and fully opaque photos never match, // so nothing that already works changes. const applyPaperBackdropIfNeeded = (img) => { try { if (!img || img.dataset.paperChecked) return; img.dataset.paperChecked = '1'; const w = img.naturalWidth, h = img.naturalHeight; if (!w || !h) return; const S = 48; // tiny sample: one cheap readback per image const c = document.createElement('canvas'); c.width = S; c.height = Math.max(1, Math.round(S * h / w)); const cx = c.getContext('2d'); cx.clearRect(0, 0, c.width, c.height); cx.drawImage(img, 0, 0, c.width, c.height); const d = cx.getImageData(0, 0, c.width, c.height).data; let clear = 0, dark = 0, light = 0, total = 0; for (let i = 0; i < d.length; i += 4) { total++; const a = d[i + 3]; if (a < 16) { clear++; continue; } const lum = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; if (lum < 60) dark++; else if (lum > 190) light++; } if (!total) return; const opaque = total - clear; const darkDominant = opaque > 0 && (dark / opaque) > 0.6; if ((clear / total) > 0.5 && darkDominant && (light / total) < 0.15) { img.classList.add('needs-paper'); } } catch (_) { // Decode hiccup or tainted canvas: leave the image exactly as it was. } }; // Graphics carousel state (mirrors Announcements; Graphics is image-only display) const GRAPHICS_CACHE_TTL_MS = 15 * 60 * 1000; let graphicsCache = { ts: 0, items: [] }; let graphicsStylesInjected = false; function getSavedGraphicIndex(tabId) { try { const raw = localStorage.getItem(`graphic-index-${tabId}`); const val = Number(raw); return Number.isFinite(val) && val >= 0 ? val : 0; } catch (_) { return 0; } } function saveGraphicIndex(tabId, index) { try { localStorage.setItem(`graphic-index-${tabId}`, String(index)); } catch (_) {} } // Stroke-based undo/redo system const strokeHistory = {}; const strokeRedo = {}; const MAX_HISTORY_SIZE = 1000; // Action types for undo/redo const ACTION_TYPES = { DRAW: 'draw', ERASE: 'erase', CLEAR: 'clear', CLEAR_ALL: 'clear_all', TOOL_CHANGE: 'tool_change', COLOR_CHANGE: 'color_change', SIZE_CHANGE: 'size_change', TAB_CREATE: 'tab_create', TAB_CLOSE: 'tab_close', TAB_SWITCH: 'tab_switch', PASSAGE_LOAD: 'passage_load' }; // Unified chronological history system for ALL user operations const unifiedHistory = []; const unifiedRedo = []; const MAX_UNIFIED_HISTORY = 1000; // Simple action tracking - store state before action function createAction(type, data = {}) { const action = { type, timestamp: Date.now(), tabId: getActiveTabId(), passageKey: getCurrentPassageKey(), data }; return action; } function pushToUnifiedHistory(operation) { // CRITICAL: Don't add operations during restoration to prevent recursive undo if (isRestoring) { if (!window.SILENCE_DEBUG) console.log('OPERATION BLOCKED: Not adding operation during restoration:', operation.type); return; } // CRITICAL: Don't add automatic operations triggered as side effects if (isAutomaticOperation) { if (!window.SILENCE_DEBUG) console.log('AUTOMATIC OPERATION BLOCKED: Not recording automatic operation:', operation.type); return; } // Operation added to unified history unifiedHistory.push(operation); // Clear redo stack when new operation is pushed unifiedRedo.length = 0; // Limit history size if (unifiedHistory.length > MAX_UNIFIED_HISTORY) { unifiedHistory.shift(); } if (!window.SILENCE_DEBUG) console.log('?? Total operations in unified history:', unifiedHistory.length); updateUndoRedoUI(); } // Legacy function for backward compatibility - now uses unified system function pushAction(action) { // All actions go into unified history pushToUnifiedHistory(action); } // Flag to prevent recursive undo operations during restoration let isRestoring = false; // Flag to prevent automatic operations from being recorded let isAutomaticOperation = false; // Helper function to run operations automatically without recording them function runAutomaticOperation(operationFunc) { const wasAutomatic = isAutomaticOperation; isAutomaticOperation = true; // Mirrored onto window so the isolated modules can tell an app-driven click on a tool button (e.g. // forcing the highlighter back to the pen on a tab switch) from a real tap by the operator. Only the // latter may switch the laser/spotlight off. try { window.__isAutomaticOperation = true; } catch (_) {} try { return operationFunc(); } finally { isAutomaticOperation = wasAutomatic; try { window.__isAutomaticOperation = wasAutomatic; } catch (_) {} } } function undoUnifiedOperation() { // Check if we have operations to undo if (unifiedHistory.length === 0) { if (!window.SILENCE_DEBUG) console.log('?? UNDO: No operations to undo'); return false; } // Prevent recursive undo operations during restoration if (isRestoring) { if (!window.SILENCE_DEBUG) console.log('?? UNDO BLOCKED: Currently restoring, preventing recursive undo'); return false; } const operationToUndo = unifiedHistory.pop(); if (!window.SILENCE_DEBUG) console.log('?? UNDOING SINGLE OPERATION:', operationToUndo.type, 'timestamp:', new Date(operationToUndo.timestamp).toLocaleTimeString()); // Set restoration flag to prevent recursive operations isRestoring = true; // Track whether the undo actually changed state; if not, don't push a phantom op to redo. let didWork = false; try { // Handle different operation types if (operationToUndo.isStroke) { // This is a stroke operation - ONLY remove the last stroke const passageKey = operationToUndo.passageKey; if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO DEBUG: Processing stroke for passage:', passageKey); if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO DEBUG: strokeHistory[passageKey] length:', strokeHistory[passageKey] ? strokeHistory[passageKey].length : 'undefined'); if (strokeHistory[passageKey] && strokeHistory[passageKey].length > 0) { didWork = true; const undoneStroke = strokeHistory[passageKey].pop(); if (!strokeRedo[passageKey]) strokeRedo[passageKey] = []; strokeRedo[passageKey].push(undoneStroke); if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO: Removed 1 stroke, remaining:', strokeHistory[passageKey].length); if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO DEBUG: Added stroke to redo stack, redo stack length:', strokeRedo[passageKey].length); if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO DEBUG: strokeHistory after undo:', strokeHistory[passageKey]); if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO DEBUG: strokeHistory.length after undo:', strokeHistory[passageKey].length); if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO DEBUG: strokeHistory content after undo:', JSON.stringify(strokeHistory[passageKey])); // CRITICAL: Save updated stroke history to localStorage so it persists across tab switches try { const data = JSON.stringify(strokeHistory[passageKey]); localStorage.setItem(passageKey + '-strokes', data); if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO: Saved updated stroke history to localStorage'); } catch (e) { console.error('?? STROKE UNDO: Failed to save to localStorage:', e); } // FIXED: Safely redraw canvas without clearing text content const tabId = getActiveTabId(); if (tabId && !drawing) { if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO: About to redraw canvas after undo'); redrawAllStrokes(tabId); // CRITICAL FIX: Also clear the visible canvas to ensure the undone stroke disappears const visibleCanvas = annotationCanvases[tabId]; if (visibleCanvas) { const visibleCtx = visibleCanvas.getContext('2d'); visibleCtx.clearRect(0, 0, visibleCanvas.width, visibleCanvas.height); if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO: Cleared visible canvas to remove undone stroke'); } } if (!window.SILENCE_DEBUG) console.log('?? STROKE UNDO: Completed successfully'); } } else { // This is an action operation - restore state safely if (!window.SILENCE_DEBUG) console.log('?? ACTION UNDO: Safely restoring state for', operationToUndo.type); safeRestoreStateBeforeAction(operationToUndo); didWork = true; if (!window.SILENCE_DEBUG) console.log('?? ACTION UNDO: Completed successfully'); } // Add to redo stack only if the undo actually changed state. If an isStroke op had no // stroke left to pop (e.g. strokes were cleared), it's a dead entry — drop it rather than // pushing a phantom step onto redo (which desynced undo/redo and logged a redo error). if (didWork) { unifiedRedo.push(operationToUndo); } } catch (error) { console.error('?? UNDO ERROR:', error); // On error, restore operation to history to prevent loss unifiedHistory.push(operationToUndo); } finally { // Always clear the restoration flag isRestoring = false; } updateUndoRedoUI(); return true; } function redoUnifiedOperation() { // Check if we have operations to redo if (unifiedRedo.length === 0) { if (!window.SILENCE_DEBUG) console.log('?? REDO: No operations to redo'); return false; } // Prevent recursive redo operations during restoration if (isRestoring) { if (!window.SILENCE_DEBUG) console.log('?? REDO BLOCKED: Currently restoring, preventing recursive redo'); return false; } const operationToRedo = unifiedRedo.pop(); if (!window.SILENCE_DEBUG) console.log('?? REDOING SINGLE OPERATION:', operationToRedo.type, 'timestamp:', new Date(operationToRedo.timestamp).toLocaleTimeString()); // Set restoration flag to prevent recursive operations isRestoring = true; try { // Handle different operation types if (operationToRedo.isStroke) { // This is a stroke operation - ONLY add back the single stroke const passageKey = operationToRedo.passageKey; if (!window.SILENCE_DEBUG) console.log('?? STROKE REDO DEBUG: Checking redo stack for passage:', passageKey); if (!window.SILENCE_DEBUG) console.log('?? STROKE REDO DEBUG: strokeRedo[passageKey]:', strokeRedo[passageKey]); if (!window.SILENCE_DEBUG) console.log('?? STROKE REDO DEBUG: strokeRedo[passageKey] length:', strokeRedo[passageKey] ? strokeRedo[passageKey].length : 'undefined'); if (strokeRedo[passageKey] && strokeRedo[passageKey].length > 0) { const redoneStroke = strokeRedo[passageKey].pop(); if (!strokeHistory[passageKey]) strokeHistory[passageKey] = []; strokeHistory[passageKey].push(redoneStroke); if (!window.SILENCE_DEBUG) console.log('?? STROKE REDO: Added 1 stroke, total now:', strokeHistory[passageKey].length); // CRITICAL: Save updated stroke history to localStorage so it persists across tab switches try { const data = JSON.stringify(strokeHistory[passageKey]); localStorage.setItem(passageKey + '-strokes', data); if (!window.SILENCE_DEBUG) console.log('?? STROKE REDO: Saved updated stroke history to localStorage'); } catch (e) { console.error('?? STROKE REDO: Failed to save to localStorage:', e); } // FIXED: Safely redraw canvas without clearing text content const tabId = getActiveTabId(); if (tabId && !drawing) { redrawAllStrokes(tabId); } if (!window.SILENCE_DEBUG) console.log('?? STROKE REDO: Completed successfully'); } else { console.error('?? STROKE REDO ERROR: No strokes in redo stack for passage:', passageKey); console.error('?? STROKE REDO ERROR: strokeRedo[passageKey]:', strokeRedo[passageKey]); console.error('?? STROKE REDO ERROR: This should not happen - stroke was not properly added to redo stack during undo'); } } else { // This is an action operation - safely reapply if (!window.SILENCE_DEBUG) console.log('?? ACTION REDO: Safely reapplying action for', operationToRedo.type); safeReapplyAction(operationToRedo); if (!window.SILENCE_DEBUG) console.log('?? ACTION REDO: Completed successfully'); } // Add back to unified history unifiedHistory.push(operationToRedo); } catch (error) { console.error('?? REDO ERROR:', error); // On error, restore operation to redo stack to prevent loss unifiedRedo.push(operationToRedo); } finally { // Always clear the restoration flag isRestoring = false; } updateUndoRedoUI(); return true; } // FIXED: Safe restoration functions to prevent text disappearing function safeRestoreStateBeforeAction(action) { if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE: Processing action type:', action.type); switch (action.type) { case ACTION_TYPES.TOOL_CHANGE: // Restore previous tool safely if (action.data.from !== undefined) { currentTool = action.data.from; updateToolUI(); if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE: Tool changed back to:', currentTool); } break; case ACTION_TYPES.COLOR_CHANGE: // Restore previous colors safely if (action.data.tool === 'pen' && action.data.from !== undefined) { penColor = action.data.from; window.penColor = penColor; const penBtn = document.getElementById('penToolBtn'); if (penBtn) penBtn.style.setProperty('--pen-color', penColor); if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE: Pen color restored to:', penColor); } if (action.data.tool === 'highlight' && action.data.from !== undefined) { highlighterColor = action.data.from; window.highlighterColor = highlighterColor; const highlightBtn = document.getElementById('highlightToolBtn'); if (highlightBtn) highlightBtn.style.setProperty('--highlighter-color', highlighterColor); if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE: Highlighter color restored to:', highlighterColor); } updateToolUI(); break; case ACTION_TYPES.SIZE_CHANGE: // Restore previous size safely (snapped to a real notch). if (action.data.from !== undefined) { setPenSize(action.data.from); if (!window.SILENCE_DEBUG) console.log('SAFE RESTORE: Pen size restored to:', penSize); } break; case ACTION_TYPES.TAB_CREATE: // Remove the tab that was created - SAFELY without affecting text if (window.bibleApp && action.data.tabId) { if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE: Removing created tab:', action.data.tabId); runAutomaticOperation(() => { window.bibleApp.closeTab(action.data.tabId); }); } break; case ACTION_TYPES.TAB_CLOSE: // Restore the tab that was closed - SAFELY if (window.bibleApp && action.data.tabData) { if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE: Restoring closed tab:', action.data.tabData.id); try { runAutomaticOperation(() => { window.bibleApp.restoreTab(action.data.tabData, action.data.wasActive); }); } catch (e) { console.error('?? SAFE RESTORE: Error restoring tab:', e); } } break; case ACTION_TYPES.CLEAR: // FIXED: Restore annotations for the specific tab - SAFELY if (action.data.clearedStrokes && action.data.tabId) { const passageKey = action.data.passageKey; if (passageKey && action.data.clearedStrokes) { if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE CLEAR: Restoring', action.data.clearedStrokes.length, 'strokes for passage:', passageKey); // Restore stroke history SAFELY strokeHistory[passageKey] = [...action.data.clearedStrokes]; // Restore to localStorage SAFELY try { const data = JSON.stringify(action.data.clearedStrokes); localStorage.setItem(passageKey + '-strokes', data); if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE CLEAR: Saved to localStorage'); } catch (e) { console.error('?? SAFE RESTORE CLEAR: Failed to save to localStorage:', e); } // Safely redraw without affecting text const currentActiveTab = getActiveTabId(); if (currentActiveTab && currentActiveTab == action.data.tabId && !drawing) { if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE CLEAR: Redrawing on active tab'); redrawAllStrokes(action.data.tabId); } else if (window.bibleApp && !drawing) { if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE CLEAR: Tab not active, switching to tab then redrawing'); runAutomaticOperation(() => { window.bibleApp.switchTab(action.data.tabId); }); setTimeout(() => { if (!drawing) redrawAllStrokes(action.data.tabId); }, 100); } } } break; case ACTION_TYPES.CLEAR_ALL: // FIXED: Restore all annotations across all tabs - SAFELY if (action.data.clearedData) { if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE CLEAR ALL: Restoring all annotations across app'); // Restore stroke history SAFELY Object.keys(action.data.clearedData.strokeHistory).forEach(key => { strokeHistory[key] = [...action.data.clearedData.strokeHistory[key]]; if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE CLEAR ALL: Restored', action.data.clearedData.strokeHistory[key].length, 'strokes for passage:', key); }); // The follower caches ink per passage; a bulk restore must be republished passage by passage. try { if (window.__followerPublish) window.__followerPublish.invalidate(); } catch (_) {} // Restore localStorage data SAFELY Object.keys(action.data.clearedData.localStorage).forEach(key => { localStorage.setItem(key, action.data.clearedData.localStorage[key]); if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE CLEAR ALL: Restored localStorage for key:', key); }); // Safely redraw all visible canvases without affecting text if (!drawing) { if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE CLEAR ALL: Redrawing', Object.keys(annotationCanvases).length, 'canvases'); Object.keys(annotationCanvases).forEach(tabId => { redrawAllStrokes(tabId); }); } if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE CLEAR ALL: Complete!'); } break; default: if (!window.SILENCE_DEBUG) console.log('?? SAFE RESTORE: Skipping unknown action type:', action.type); break; } } function safeReapplyAction(action) { if (!window.SILENCE_DEBUG) console.log('?? SAFE REAPPLY: Processing action type:', action.type); switch (action.type) { case ACTION_TYPES.TOOL_CHANGE: // Reapply tool change safely if (action.data.to !== undefined) { currentTool = action.data.to; updateToolUI(); if (!window.SILENCE_DEBUG) console.log('?? SAFE REAPPLY: Tool changed to:', currentTool); } break; case ACTION_TYPES.COLOR_CHANGE: // Reapply color changes safely if (action.data.tool === 'pen' && action.data.to !== undefined) { penColor = action.data.to; window.penColor = penColor; const penBtn = document.getElementById('penToolBtn'); if (penBtn) penBtn.style.setProperty('--pen-color', penColor); if (!window.SILENCE_DEBUG) console.log('?? SAFE REAPPLY: Pen color changed to:', penColor); } if (action.data.tool === 'highlight' && action.data.to !== undefined) { highlighterColor = action.data.to; window.highlighterColor = highlighterColor; const highlightBtn = document.getElementById('highlightToolBtn'); if (highlightBtn) highlightBtn.style.setProperty('--highlighter-color', highlighterColor); if (!window.SILENCE_DEBUG) console.log('?? SAFE REAPPLY: Highlighter color changed to:', highlighterColor); } updateToolUI(); break; case ACTION_TYPES.SIZE_CHANGE: // Reapply size change safely (snapped to a real notch). if (action.data.to !== undefined) { setPenSize(action.data.to); if (!window.SILENCE_DEBUG) console.log('SAFE REAPPLY: Pen size changed to:', penSize); } break; case ACTION_TYPES.CLEAR: // FIXED: Directly clear the annotations without creating new actions if (action.data.tabId) { if (!window.SILENCE_DEBUG) console.log('?? SAFE REAPPLY CLEAR: Directly clearing tab:', action.data.tabId); const tabId = action.data.tabId; const passageKey = action.data.passageKey; // Clear canvases 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); } // Clear stroke data if (passageKey) { localStorage.removeItem(passageKey + '-strokes'); if (strokeHistory[passageKey]) { strokeHistory[passageKey] = []; } if (strokeRedo[passageKey]) { strokeRedo[passageKey] = []; } } updateUndoRedoUI(); } break; case ACTION_TYPES.CLEAR_ALL: // FIXED: Directly clear all annotations without creating new actions if (!window.SILENCE_DEBUG) console.log('?? SAFE REAPPLY CLEAR ALL: Directly clearing all annotations across app'); // 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 const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.endsWith('-strokes')) { keysToRemove.push(key); } } keysToRemove.forEach(key => localStorage.removeItem(key)); // Popup-card marks live under '-popup-strokes', so the sweep above already removed // them from storage — repaint any OPEN card so the screen agrees with storage. try { if (window.popupDraw) window.popupDraw.clearAllForPassage(); } catch (_) {} // Clear all stroke history and redo stacks Object.keys(strokeHistory).forEach(key => { strokeHistory[key] = []; }); Object.keys(strokeRedo).forEach(key => { strokeRedo[key] = []; }); // Bulk clear: the follower must drop every cached list too (see clearAllAnnotationsAcrossApp). try { if (window.__followerPublish) window.__followerPublish.clearAll(); } catch (_) {} updateUndoRedoUI(); break; default: if (!window.SILENCE_DEBUG) console.log('?? SAFE REAPPLY: Skipping unknown action type:', action.type); break; } } // Legacy functions for backward compatibility function undoAction() { return undoUnifiedOperation(); } function redoAction() { return redoUnifiedOperation(); } function restoreStateBeforeAction(action) { switch (action.type) { case ACTION_TYPES.TOOL_CHANGE: // Restore previous tool if (action.data.from !== undefined) { currentTool = action.data.from; updateToolUI(); } break; case ACTION_TYPES.COLOR_CHANGE: // Restore previous colors if (action.data.tool === 'pen' && action.data.from !== undefined) { penColor = action.data.from; window.penColor = penColor; const penBtn = document.getElementById('penToolBtn'); if (penBtn) penBtn.style.setProperty('--pen-color', penColor); } if (action.data.tool === 'highlight' && action.data.from !== undefined) { highlighterColor = action.data.from; window.highlighterColor = highlighterColor; const highlightBtn = document.getElementById('highlightToolBtn'); if (highlightBtn) highlightBtn.style.setProperty('--highlighter-color', highlighterColor); } updateToolUI(); break; case ACTION_TYPES.SIZE_CHANGE: // Restore previous size (snapped to a real notch). if (action.data.from !== undefined) { setPenSize(action.data.from); } break; case ACTION_TYPES.TAB_CREATE: // Remove the tab that was created if (window.bibleApp && action.data.tabId) { runAutomaticOperation(() => { window.bibleApp.closeTab(action.data.tabId); }); } break; case ACTION_TYPES.TAB_CLOSE: // Restore the tab that was closed if (window.bibleApp && action.data.tabData) { if (!window.SILENCE_DEBUG) console.log('?? RESTORING TAB: Restoring closed tab:', action.data.tabData.id); if (!window.SILENCE_DEBUG) console.log('?? TAB DATA:', action.data.tabData); try { // Use the restoreTab method but ensure it works properly runAutomaticOperation(() => { window.bibleApp.restoreTab(action.data.tabData, action.data.wasActive); }); console.log('? TAB RESTORE: Successfully called restoreTab'); // Verify the tab was actually restored const restoredTab = window.bibleApp.tabs.find(t => t.id == action.data.tabData.id); if (restoredTab) { console.log('? TAB RESTORE: Tab found in tabs array after restoration'); } else { console.error('? TAB RESTORE: Tab not found in tabs array after restoration'); } } catch (error) { console.error('? TAB RESTORE: Error during tab restoration:', error); } } else { console.error('? TAB RESTORE: Missing bibleApp or tabData'); } break; case ACTION_TYPES.TAB_SWITCH: // Switch back to previous tab if (window.bibleApp && action.data.fromTabId) { runAutomaticOperation(() => { window.bibleApp.switchTab(action.data.fromTabId); }); } break; case ACTION_TYPES.CLEAR: // Restore annotations for the specific tab if (action.data.clearedStrokes && action.data.tabId) { const passageKey = action.data.passageKey; if (passageKey && action.data.clearedStrokes) { console.log('?? RESTORING CLEAR: Restoring', action.data.clearedStrokes.length, 'strokes for passage:', passageKey); // Restore stroke history strokeHistory[passageKey] = [...action.data.clearedStrokes]; // Restore to localStorage try { const data = JSON.stringify(action.data.clearedStrokes); localStorage.setItem(passageKey + '-strokes', data); console.log('? CLEAR RESTORE: Saved to localStorage'); } catch (e) { console.error('? CLEAR RESTORE: Failed to save to localStorage:', e); } // Make sure the tab is active before redrawing const currentActiveTab = getActiveTabId(); if (currentActiveTab && currentActiveTab == action.data.tabId) { console.log('?? CLEAR RESTORE: Redrawing on active tab'); redrawAllStrokes(action.data.tabId); } else { console.log('?? CLEAR RESTORE: Tab not active, switching to tab then redrawing'); if (window.bibleApp) { runAutomaticOperation(() => { window.bibleApp.switchTab(action.data.tabId); }); setTimeout(() => redrawAllStrokes(action.data.tabId), 100); } } } } break; case ACTION_TYPES.CLEAR_ALL: // Restore all annotations across all tabs if (action.data.clearedData) { console.log('?? RESTORING CLEAR ALL: Restoring all annotations across app'); // Restore stroke history Object.keys(action.data.clearedData.strokeHistory).forEach(key => { strokeHistory[key] = [...action.data.clearedData.strokeHistory[key]]; console.log('? CLEAR ALL RESTORE: Restored', action.data.clearedData.strokeHistory[key].length, 'strokes for passage:', key); }); // The follower caches ink per passage; a bulk restore must be republished passage by passage. try { if (window.__followerPublish) window.__followerPublish.invalidate(); } catch (_) {} // Restore localStorage data Object.keys(action.data.clearedData.localStorage).forEach(key => { localStorage.setItem(key, action.data.clearedData.localStorage[key]); console.log('? CLEAR ALL RESTORE: Restored localStorage for key:', key); }); // Redraw all visible canvases console.log('?? CLEAR ALL RESTORE: Redrawing', Object.keys(annotationCanvases).length, 'canvases'); Object.keys(annotationCanvases).forEach(tabId => { redrawAllStrokes(tabId); }); console.log('? CLEAR ALL RESTORE: Complete!'); } break; case ACTION_TYPES.PASSAGE_LOAD: // Restore previous passage content if (window.bibleApp && action.data.tabId && action.data.previousPassage) { const tab = window.bibleApp.tabs.find(t => t.id == action.data.tabId); if (tab) { Object.assign(tab, action.data.previousPassage); window.bibleApp.renderTabContent(tab); if (action.data.previousPassage.loaded) { window.bibleApp.restoreTabContent(tab); } } } break; } } function reapplyAction(action) { switch (action.type) { case ACTION_TYPES.TOOL_CHANGE: // Reapply the tool change currentTool = action.data.to; updateToolUI(); break; case ACTION_TYPES.COLOR_CHANGE: // Reapply the color change if (action.data.tool === 'pen') { penColor = action.data.to; window.penColor = penColor; const penBtn = document.getElementById('penToolBtn'); if (penBtn) penBtn.style.setProperty('--pen-color', penColor); } else if (action.data.tool === 'highlight') { highlighterColor = action.data.to; window.highlighterColor = highlighterColor; const highlightBtn = document.getElementById('highlightToolBtn'); if (highlightBtn) highlightBtn.style.setProperty('--highlighter-color', highlighterColor); } updateToolUI(); break; case ACTION_TYPES.SIZE_CHANGE: // Reapply the size change (snapped to a real notch). if (action.data.tool === 'pen') { setPenSize(action.data.to); } break; case ACTION_TYPES.TAB_CREATE: // Recreate the tab if (window.bibleApp && action.data.tabData) { runAutomaticOperation(() => { window.bibleApp.restoreTab(action.data.tabData, false); }); } break; case ACTION_TYPES.TAB_CLOSE: // Re-close the tab if (window.bibleApp && action.data.tabId) { runAutomaticOperation(() => { window.bibleApp.closeTab(action.data.tabId); }); } break; case ACTION_TYPES.TAB_SWITCH: // Switch to the target tab if (window.bibleApp && action.data.toTabId) { runAutomaticOperation(() => { window.bibleApp.switchTab(action.data.toTabId); }); } break; case ACTION_TYPES.CLEAR: // Re-clear the annotations if (action.data.tabId) { runAutomaticOperation(() => { clearAllAnnotations(action.data.tabId); }); } break; case ACTION_TYPES.CLEAR_ALL: // Re-clear all annotations runAutomaticOperation(() => { clearAllAnnotationsAcrossApp(); }); break; case ACTION_TYPES.PASSAGE_LOAD: // Re-load the passage if (window.bibleApp && action.data.tabId && action.data.newPassage) { const tab = window.bibleApp.tabs.find(t => t.id == action.data.tabId); if (tab) { Object.assign(tab, action.data.newPassage); window.bibleApp.loadPassage(action.data.tabId); } } break; } } function redrawStroke(tabId, strokeData) { console.log('Redrawing stroke:', strokeData); } function getCurrentDrawingSettings() { if (currentTool === 'pen') { return { color: penColor, size: penSize, alpha: 1.0, composite: 'source-over', smooth: true }; } else if (currentTool === 'highlight') { const tabId = getActiveTabId(); const readingArea = document.getElementById(`readingArea${tabId}`); let textEl = readingArea ? readingArea.querySelector('.bible-text') : null; let fontSize = textEl ? parseInt(window.getComputedStyle(textEl).fontSize) : 24; let highlightSize = Math.max(fontSize * 1.5, 20); return { color: highlighterColor, size: highlightSize, alpha: 0.3, composite: 'source-over', smooth: true }; } else if (currentTool === 'erase') { // Make eraser 1.3x the highlighter size, or at least 24px const tabId = getActiveTabId(); const readingArea = document.getElementById(`readingArea${tabId}`); let textEl = readingArea ? readingArea.querySelector('.bible-text') : null; let fontSize = textEl ? parseInt(window.getComputedStyle(textEl).fontSize) : 24; let highlightSize = Math.max(fontSize * 1.5, 20); let eraserSize = Math.max(highlightSize * 1.3, 24); return { color: '#000', size: eraserSize, alpha: 1.0, composite: 'destination-out', smooth: true }; } return null; } // Make the function globally available for media handler window.getCurrentDrawingSettings = getCurrentDrawingSettings; function forcePenReadyForTab(tabId) { try { const tab = getTabById(tabId); if (!tab || tab.mode !== 'bible') return; window.currentTool = 'pen'; if (typeof currentTool !== 'undefined') currentTool = 'pen'; if (typeof updateToolUI === 'function') updateToolUI(); const ra = document.getElementById(`readingArea${tabId}`); if (ra) ra.style.cursor = 'crosshair'; setupAnnotationForActiveTab(); } catch (_) {} } function ensurePenDrawingReady(tabId) { try { const tab = getTabById(tabId); if (!tab || (tab.mode !== 'bible' && tab.mode !== 'alpha')) return; drawing = false; // Reset any stuck multi-touch state that blocks drawing try { if (typeof twoFingerScroll !== 'undefined') { twoFingerScroll.active = false; twoFingerScroll.scrollTarget = null; } globalMultiTouchActive = false; } catch (_) {} window.currentTool = 'pen'; if (typeof currentTool !== 'undefined') currentTool = 'pen'; window.selectModeActive = false; if (typeof selectModeActive !== 'undefined') selectModeActive = false; if (typeof updateToolUI === 'function') updateToolUI(); if (typeof updateCanvasPointerEvents === 'function') updateCanvasPointerEvents(); const ra = document.getElementById(`readingArea${tabId}`); if (ra) { ra.style.cursor = 'crosshair'; ra.style.pointerEvents = 'auto'; } const canvas = getOrCreateAnnotationCanvas(tabId); if (canvas) { canvas.style.pointerEvents = 'auto'; canvas.style.cursor = 'crosshair'; } attachAnnotationListeners(tabId); } catch (_) {} } function ensureNonZeroCanvas(tabId) { try { const ra = document.getElementById(`readingArea${tabId}`); const canvas = getOrCreateAnnotationCanvas(tabId); if (!ra || !canvas) return; let w = ra.clientWidth; let h = ra.clientHeight; if (!w || !h) { w = window.innerWidth || 1200; h = window.innerHeight || 1800; ra.style.minHeight = `${Math.max(h, 1200)}px`; } if (w > 0 && h > 0) { canvas.width = w * 2; canvas.height = Math.max(h * 2, 4000); canvas.style.width = `${w}px`; canvas.style.height = `${Math.max(h, 2000)}px`; updateVisibleCanvas(tabId); } } catch (_) {} } function ensureCanvasAndPenReady(tabId, attempt = 0) { try { const maxAttempts = 6; if (!tabId || attempt > maxAttempts) return; const ra = document.getElementById(`readingArea${tabId}`); const canvas = getOrCreateAnnotationCanvas(tabId); const okSize = ra && ra.clientWidth > 10 && ra.clientHeight > 10; if (canvas) { canvas.style.pointerEvents = 'auto'; canvas.style.cursor = 'crosshair'; } if (ra) { ra.style.pointerEvents = 'auto'; ra.style.cursor = 'crosshair'; } if (okSize) { updateVisibleCanvas(tabId); redrawAllStrokes(tabId); ensurePenDrawingReady(tabId); return; } setTimeout(() => ensureCanvasAndPenReady(tabId, attempt + 1), 80); } catch (_) {} } const PRAYER_CONTENT_PRIMARY_KEY = 'prayer-content-primary'; function getPrayerContentSnapshot(tab) { let best = ''; const candidates = []; try { if (tab && tab.prayerContent && tab.prayerContent.trim()) { candidates.push(tab.prayerContent); } } catch (_) {} try { if (tab && tab.id) { const saved = localStorage.getItem(`prayer-content-${tab.id}`); if (saved && saved.trim()) candidates.push(saved); } } catch (_) {} try { const saved = localStorage.getItem(PRAYER_CONTENT_PRIMARY_KEY); if (saved && saved.trim()) candidates.push(saved); } catch (_) {} candidates.forEach((content) => { if (content && content.length > best.length) { best = content; } }); return best; } function persistPrayerContent(tabId, content, allowEmpty = false) { const safeContent = content || ''; if (!allowEmpty && (!safeContent || !safeContent.trim())) return; try { if (tabId) localStorage.setItem(`prayer-content-${tabId}`, safeContent); } catch (_) {} try { localStorage.setItem(PRAYER_CONTENT_PRIMARY_KEY, safeContent); } catch (_) {} } function clearPrayerContentStorage(tabId) { const keysToRemove = []; try { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith('prayer-content-')) { keysToRemove.push(key); } } } catch (_) {} keysToRemove.forEach((key) => { try { localStorage.removeItem(key); } catch (_) {} }); persistPrayerContent(tabId, '', true); } /* ===================================================================== * ASK TAB — offline-first AI assistant * -------------------------------------------------------------------- * - Voice input via Web Speech API (no audio response, text only). * - Math is solved by a deterministic local parser for guaranteed * accuracy (the LLM is never trusted with numbers). * - Falls through to Ollama (localhost:11434) for general questions. * - Optional API providers (OpenAI / Anthropic) configured in Settings. * - Conversation history persists in localStorage per tab. * ===================================================================== */ const ASK_DEFAULT_MODEL = 'llama3.2'; const ASK_HISTORY_PREFIX = 'ask-history-'; const ASK_PROVIDER_KEY = 'ask.activeProvider'; const ASK_KEYS_STORAGE_KEY = 'ask.providers'; const ASK_OLLAMA_MODEL_KEY = 'ask.ollamaModel'; /** * Profanity filter for the Ask tab. The list covers the most common English * profanities and a handful of slurs/severe terms; words are matched * whole-word, case-insensitively, and replaced with the same number of * asterisks so we never leak partial letters. We deliberately keep the list * conservative \u2014 not a full lexicon \u2014 so it can be edited later if needed. */ const ASK_PROFANITY_LIST = [ 'fuck','fucks','fucked','fucker','fuckers','fucking','fck','fuk','fking','fkn','fukin','motherfucker','motherfuckers','motherfucking','mfer','clusterfuck', 'shit','shits','shitty','shitting','shitter','bullshit','horseshit','sht','dipshit','batshit','shitload', 'bitch','bitches','bitchy','bitching','biatch','beotch','sonofabitch', 'bastard','bastards', 'asshole','assholes','asshat','asshats','jackass','dumbass','smartass','badass','dipass','arsehole', 'damn','damned','damnit','goddamn','goddamned','goddammit','dammit', 'hell', 'piss','pissed','pisser','pissing', 'crap','crappy','craphole', 'cock','cocks','cocksucker','cocksuckers', 'dick','dicks','dickhead','dickheads','dickwad','dickweed', 'pussy','pussies', 'cunt','cunts','cunty', 'twat','twats', 'whore','whores','whoring', 'slut','sluts','slutty', 'douche','douchebag','douchebags','douchey', 'prick','pricks', 'wanker','wankers','wank', 'bollocks','bugger','buggered','buggering','bloody', 'jerkoff','jackoff', 'tits','titties','boobs', 'bastard','bastards', 'nigger','niggers','nigga','niggas','faggot','faggots','fag','fags','retard','retards','retarded', 'screwed','screwing','screwer','sumbitch' ]; const ASK_PROFANITY_REGEX = (() => { // Word-boundary, case-insensitive, longest-first to prefer specific matches. const sorted = ASK_PROFANITY_LIST.slice().sort((a, b) => b.length - a.length); const escaped = sorted.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); return new RegExp('\\b(?:' + escaped.join('|') + ')\\b', 'gi'); })(); /** * Replace any matched profanity with asterisks of the same length. Keeping * the original length preserves spacing when censoring mid-stream tokens. */ function censorProfanity(text) { if (typeof text !== 'string' || !text) return text; return text.replace(ASK_PROFANITY_REGEX, (m) => '*'.repeat(m.length)); } function loadAskMessages(tabId) { try { const raw = localStorage.getItem(ASK_HISTORY_PREFIX + String(tabId)); if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } } function saveAskMessages(tabId, messages) { try { localStorage.setItem(ASK_HISTORY_PREFIX + String(tabId), JSON.stringify(messages || [])); } catch (_) {} } function clearAskMessages(tabId) { try { localStorage.removeItem(ASK_HISTORY_PREFIX + String(tabId)); } catch (_) {} } function loadAskProviders() { // Returns array of { id, name, provider, baseUrl, apiKey, model } // 'llama' (bundled local AI) and 'ollama' (auto-detected) are always available // and not editable. The user can also add OpenAI/Anthropic/etc. as fallback. let stored = []; try { const raw = localStorage.getItem(ASK_KEYS_STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) stored = parsed; } } catch (_) {} const ollamaModel = (() => { try { return localStorage.getItem(ASK_OLLAMA_MODEL_KEY) || ASK_DEFAULT_MODEL; } catch (_) { return ASK_DEFAULT_MODEL; } })(); return [ { id: 'llama', name: 'Local AI — Llama 3.2 3B (bundled, fully offline)', provider: 'llama', baseUrl: '', apiKey: '', model: 'local', builtIn: true }, { id: 'ollama', name: 'Ollama (local, optional)', provider: 'ollama', baseUrl: 'http://localhost:11434', apiKey: '', model: ollamaModel, builtIn: true }, ...stored.filter(p => p && p.id && p.id !== 'ollama' && p.id !== 'llama') ]; } function getActiveAskProviderId() { try { return localStorage.getItem(ASK_PROVIDER_KEY) || 'llama'; } catch (_) { return 'llama'; } } function setActiveAskProviderId(id) { try { localStorage.setItem(ASK_PROVIDER_KEY, String(id || 'llama')); } catch (_) {} } /** * Deterministic math evaluator. Handles +, -, *, /, %, ^, parentheses, and * common functions (sqrt, sin, cos, tan, log, ln, abs, round, floor, ceil). * Returns { value, formatted } or null if input is not a math expression. */ function tryEvaluateMath(rawInput) { if (!rawInput || typeof rawInput !== 'string') return null; let s = rawInput.toLowerCase().trim(); // Strip a leading question word so "what is 2+2" still evaluates. s = s.replace(/^(what(?:'s| is)|whats|calc(?:ulate)?|compute|solve|how much is|equals?)\s*/i, ''); s = s.replace(/[?!.,]+$/g, '').trim(); s = s.replace(/\s+/g, ' '); // Word -> symbol substitutions for spoken math. const wordMap = [ [/\bplus\b/g, '+'], [/\b(?:minus|subtract(?:ed)? from|less)\b/g, '-'], [/\b(?:times|multiplied by|x)\b/g, '*'], [/\b(?:divided by|over)\b/g, '/'], [/\bmod(?:ulo)?\b/g, '%'], [/\bto the power of\b/g, '^'], [/\bsquared\b/g, '^2'], [/\bcubed\b/g, '^3'], [/\bpercent of\b/g, '*0.01*'], [/\bpercent\b/g, '*0.01'], [/\bpi\b/g, '(' + Math.PI + ')'], [/\beuler\b/g, '(' + Math.E + ')'] ]; for (const [re, rep] of wordMap) s = s.replace(re, rep); // Spelled-out small numbers. const numWords = { zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90, hundred: 100, thousand: 1000, million: 1000000 }; s = s.replace(/\b([a-z]+)\b/g, (m, w) => numWords[w] !== undefined ? String(numWords[w]) : m); // Allow only the characters of a sane math expression. if (!/^[\s0-9+\-*/().,%^a-z]+$/.test(s)) return null; if (!/[0-9]/.test(s)) return null; // Need at least one operator OR a function call to count as math. const hasOp = /[+\-*/%^]/.test(s); const hasFn = /\b(sqrt|sin|cos|tan|log|ln|abs|round|floor|ceil)\s*\(/.test(s); if (!hasOp && !hasFn) return null; s = s.replace(/,/g, ''); s = s.replace(/\^/g, '**'); // Reject any remaining identifiers that aren't allowed functions. const allowedIdents = new Set(['sqrt','sin','cos','tan','log','ln','abs','round','floor','ceil','pi','e']); const idents = s.match(/[a-z_][a-z_0-9]*/g) || []; for (const id of idents) { if (!allowedIdents.has(id)) return null; } // Map function names to Math.* equivalents. const fnMap = { sqrt: 'Math.sqrt', sin: 'Math.sin', cos: 'Math.cos', tan: 'Math.tan', log: 'Math.log10', ln: 'Math.log', abs: 'Math.abs', round: 'Math.round', floor: 'Math.floor', ceil: 'Math.ceil' }; let safe = s.replace(/\b(sqrt|sin|cos|tan|log|ln|abs|round|floor|ceil)\b/g, (m) => fnMap[m] || m); safe = safe.replace(/\bpi\b/g, '(Math.PI)').replace(/\be\b/g, '(Math.E)'); let value; try { // Sandbox: Function with no closure, only Math is exposed. // eslint-disable-next-line no-new-func const fn = new Function('Math', `"use strict"; return (${safe});`); value = fn(Math); } catch (_) { return null; } if (typeof value !== 'number' || !isFinite(value)) return null; let formatted; if (Number.isInteger(value)) { formatted = value.toLocaleString('en-US'); } else { const rounded = Math.round(value * 1e10) / 1e10; formatted = rounded.toLocaleString('en-US', { maximumFractionDigits: 10 }); } return { value, formatted, expression: rawInput.trim() }; } function escapeAskHtml(text) { return String(text == null ? '' : text) .replace(/&/g, '&') .replace(//g, '>') .replace(/\"/g, '"') .replace(/'/g, '''); } function renderAskHistory(tabId, messages) { const el = document.getElementById(`askHistory${tabId}`); if (!el) return; if (!messages || !messages.length) { el.innerHTML = ''; return; } // Group messages into Q&A pairs, then render pairs in reverse // chronological order (newest pair at the top of the page) while keeping // the question above its answer within each pair so each exchange still // reads naturally top-to-bottom. const pairs = []; let pending = null; for (const m of messages) { if (!m) continue; if (m.role === 'user') { if (pending) pairs.push([pending, null]); pending = m; } else if (m.role === 'assistant') { if (pending) { pairs.push([pending, m]); pending = null; } else { pairs.push([null, m]); } } } if (pending) pairs.push([pending, null]); const renderBubble = (m, role) => { let safe = escapeAskHtml(censorProfanity(m.content || '')).replace(/\n/g, '
'); // AI answers: make scripture references tappable (opens a new Bible tab at that verse). if (role === 'assistant') safe = linkifyScriptureRefs(safe); const label = role === 'user' ? 'You' : 'AI'; return `
${label}
${safe}
`; }; const rows = []; for (let i = pairs.length - 1; i >= 0; i--) { const [q, a] = pairs[i]; if (q) rows.push(renderBubble(q, 'user')); if (a) rows.push(renderBubble(a, 'assistant')); } el.innerHTML = rows.join(''); } // ---- Tappable scripture references in AI answers ---- // Wraps references like "Exodus 7:14-24", "1 Corinthians 10:10-11", "Exodus 7:25-8:15" or // "Psalm 23" in the (already-escaped) answer HTML; tapping opens a NEW Bible tab at that verse. function linkifyScriptureRefs(escapedHtml) { try { if (!window.__askRefRegex) { const alt = __GROUNDING_BOOKS.map(b => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+')).join('|') + '|Psalm|Song\\s+of\\s+Songs'; // Book + chapter, optional :verse, optional range (-verse or -chapter:verse) window.__askRefRegex = new RegExp('\\b(' + alt + ')\\s+(\\d{1,3})(?::(\\d{1,3}))?(?:\\s*[-–]\\s*(?:(\\d{1,3}):)?(\\d{1,3}))?', 'gi'); } return String(escapedHtml || '').replace(window.__askRefRegex, (match, book, chapter, verse, endChap, endVerse) => { const normBook = __normalizeGroundingBook(book); if (!normBook) return match; const v = verse ? parseInt(verse, 10) : 1; // Range within the same chapter -> keep verseEnd; cross-chapter range -> open at the start. const vEnd = (endVerse && !endChap && verse) ? parseInt(endVerse, 10) : null; return `${match}`; }); } catch (_) { return escapedHtml; } } // Open a passage in a brand-new Bible tab (used by tapped references; mirrors the remote's go-to). function openPassageInNewTab(book, chapter, verse, verseEnd) { try { const app = window.bibleApp; if (!app || !book || !chapter) return; app.createNewTab(); const tab = app.tabs.filter(t => t.mode === 'bible').slice(-1)[0]; if (!tab) return; tab.book = book; tab.chapter = parseInt(chapter, 10) || 1; tab.verse = parseInt(verse, 10) || 1; tab.verseEnd = verseEnd ? (parseInt(verseEnd, 10) || null) : null; tab.loaded = false; // Scroll to the tapped verse once the chapter renders (the chapter opens AT the verse, // not at the top). const wantVerse = tab.verse > 1 ? tab.verse : null; if (wantVerse) { tab._scrollToVerse = wantVerse; tab._jumpRetries = 0; } if (typeof app.switchTab === 'function') app.switchTab(tab.id); // Let the new tab's initial render settle, then load the passage over it (avoids the // selection-screen race — same pattern as the phone remote's go-to). setTimeout(() => { try { if (typeof app.loadPassage === 'function') app.loadPassage(tab.id); } catch (_) {} }, 200); // Fresh chapters load slower than the scroller's retry budget, and the renderer resets // scroll to top after painting — re-arm the scroll until it lands (no-op once cleared). if (wantVerse) { [900, 1800, 3200, 5000].forEach(ms => setTimeout(() => { try { if (tab._scrollToVerse) { tab._jumpRetries = 0; if (typeof app.autoScrollToSelectedVerse === 'function') app.autoScrollToSelectedVerse(tab.id); } } catch (_) {} }, ms)); } } catch (_) {} } try { window.openPassageInNewTab = openPassageInNewTab; } catch (_) {} // One delegated listener: any tapped .ask-ref opens its passage. try { if (!window.__askRefTapWired) { window.__askRefTapWired = true; document.addEventListener('click', (e) => { try { const ref = e.target && e.target.closest ? e.target.closest('.ask-ref') : null; if (!ref) return; e.preventDefault(); e.stopPropagation(); openPassageInNewTab(ref.dataset.book, ref.dataset.chapter, ref.dataset.verse, ref.dataset.verseend); } catch (_) {} }, true); } } catch (_) {} // ---- AI scripture grounding (100% offline retrieval / RAG) ---- // Returns [{ref, text}] of relevant verses so streamAskAnswer can inject them into the system prompt. // Explicit references in the question are looked up via the app's existing loader; topical questions // fall back to a lightweight keyword search over a cached ESV index. Purely additive — the visible // answer format never changes. const __GROUNDING_BOOKS = ['Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth','1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah','Esther','Job','Psalms','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi','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']; const __GROUNDING_STOPWORDS = new Set(['the','and','what','does','say','said','about','that','this','with','from','for','are','was','were','you','your','our','his','her','him','she','they','them','who','how','why','when','where','which','into','than','then','have','has','had','will','would','should','could','can','may','might','shall','not','but','all','any','some','one','two','their','there','here','been','being','done','tell','mean','means','verse','verses','bible','scripture','passage','chapter','explain','meaning','according']); let __groundingIndex = null; let __groundingIndexPromise = null; function __normalizeGroundingBook(name) { const n = String(name || '').trim().replace(/\s+/g, ' ').toLowerCase(); for (let i = 0; i < __GROUNDING_BOOKS.length; i++) if (__GROUNDING_BOOKS[i].toLowerCase() === n) return __GROUNDING_BOOKS[i]; if (n === 'song of songs') return 'Song of Solomon'; if (n === 'psalm') return 'Psalms'; // Abbreviations ("Heb", "1 Cor", "Ps") via the shared table, which also refuses to guess between two // books. Full names are matched above first, so this only ever adds resolutions. try { if (window.BibleBooks && typeof window.BibleBooks.resolveBook === 'function') { const viaAlias = window.BibleBooks.resolveBook(name); if (viaAlias) return viaAlias; } } catch (_) {} return null; } function __parseScriptureReferences(text) { const alt = __GROUNDING_BOOKS.map(b => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+')).join('|'); const re = new RegExp('\\b(' + alt + ')\\s+(\\d+):(\\d+)(?:\\s*-\\s*(\\d+))?', 'gi'); const out = []; let m; while ((m = re.exec(text)) && out.length < 3) { const book = __normalizeGroundingBook(m[1]); if (!book) continue; out.push({ book, chapter: parseInt(m[2], 10), vStart: parseInt(m[3], 10), vEnd: m[4] ? parseInt(m[4], 10) : parseInt(m[3], 10) }); } return out; } function __extractGroundingKeywords(text) { return String(text || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/) .filter(w => w.length > 3 && !__GROUNDING_STOPWORDS.has(w)).slice(0, 8); } function __ensureGroundingIndex() { if (__groundingIndex) return Promise.resolve(__groundingIndex); if (__groundingIndexPromise) return __groundingIndexPromise; __groundingIndexPromise = fetch('/ESV_bible.json', { cache: 'force-cache' }) .then(r => (r && r.ok) ? r.json() : null) .then(data => { const arr = []; if (data && typeof data === 'object') { for (const book of Object.keys(data)) { const chapters = data[book]; if (!chapters || typeof chapters !== 'object') continue; for (const ch of Object.keys(chapters)) { const verses = chapters[ch]; if (!verses || typeof verses !== 'object') continue; for (const v of Object.keys(verses)) { const t = verses[v]; if (typeof t === 'string' && t.length >= 20) arr.push({ ref: book + ' ' + ch + ':' + v, text: t, lc: t.toLowerCase() }); } } } } __groundingIndex = arr; return arr; }) .catch(() => { __groundingIndex = []; return __groundingIndex; }); return __groundingIndexPromise; } async function retrieveGroundingContext(userText) { try { const app = window.bibleApp; // 1) Explicit references in the question -> inject those exact verses. const refs = __parseScriptureReferences(userText); if (refs.length && app && typeof app.loadLocalPassageFromTranslations === 'function') { const results = []; for (const r of refs) { try { const verses = await app.loadLocalPassageFromTranslations('ESV', r.book, r.chapter, r.vStart, r.vEnd); if (Array.isArray(verses)) verses.slice(0, 6).forEach(v => { if (v && v.text) results.push({ ref: r.book + ' ' + r.chapter + ':' + v.verse, text: String(v.text).trim() }); }); } catch (_) {} } if (results.length) return results.slice(0, 6); } // 2) Topical question -> lightweight keyword search over the ESV index. const kws = __extractGroundingKeywords(userText); // Need at least 2 content keywords: topical grounding only fires when 2+ distinct terms // co-occur in a verse, which keeps hits highly relevant and avoids single-common-word noise. if (kws.length < 2) return []; const idx = await __ensureGroundingIndex(); if (!idx || !idx.length) return []; // Word-boundary prefix regexes so word-form variants match (anxiety~anxious, patience~patient, love~loved). const res = kws.map(k => { const p = (k.length >= 6 ? k.slice(0, 4) : k).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp('\\b' + p, 'i'); }); // Book-aware bias: if the question NAMES a book ("...in Jonah", "...the book of Romans"), // verses from that book outrank same-score hits elsewhere — answers stay in the right context. const qLower = String(userText || '').toLowerCase(); const namedBooks = __GROUNDING_BOOKS.filter(b => qLower.includes(b.toLowerCase())); const scoreAll = (minHits) => { const out = []; for (let i = 0; i < idx.length; i++) { const lc = idx[i].lc; let score = 0; for (let k = 0; k < res.length; k++) if (res[k].test(lc)) score++; if (score >= minHits) { if (namedBooks.length && namedBooks.some(b => idx[i].ref.startsWith(b + ' '))) score += 2; out.push([score, i]); } } out.sort((a, b) => b[0] - a[0]); return out; }; // Prefer verses where 2+ terms co-occur (high precision). If nothing co-occurs — common for // name questions like "Methuselah" or "Enoch" where only the rare name matches — fall back to // RARE single-keyword hits (a keyword matching few verses is highly informative; common words // that hit hundreds of verses stay excluded, so noise stays low). let scored = scoreAll(2); if (!scored.length) { const single = scoreAll(1); if (single.length && single.length <= 120) scored = single; } return scored.slice(0, 4).map(s => ({ ref: idx[s[1]].ref, text: idx[s[1]].text })); } catch (_) { return []; } } try { window.retrieveGroundingContext = retrieveGroundingContext; } catch (_) {} // Static Ask system prompt. IMPORTANT: this exact string is also sent by the boot pre-warm so // llama caches it as a KV prefix — the first live question then only processes the small // grounding + question suffix instead of the whole prompt. Keep prompt and pre-warm identical. const ASK_SYSTEM_PROMPT = 'You are a direct answer engine for a Bible study app used live in church. Output ONLY the answer.\n' + 'Hard rules:\n' + '• NEVER repeat, restate, paraphrase, or quote the user\'s question.\n' + '• NEVER start with phrases like "The answer is", "You asked", "Sure", "Well", "I think", or any restatement.\n' + '• NEVER end with a follow-up question or filler.\n' + '• Simple fact questions: one short sentence with the reference.\n' + '• Teaching questions ("What does the Bible say about...", "Why...", "Explain..."): give a COMPLETE answer — 2 to 4 clear sentences summarizing what Scripture teaches, THEN the references. NEVER answer with references alone; always say what the Bible actually teaches.\n' + '• Be BIBLICALLY PRECISE: use the Bible\'s own wording for facts, and answer in the context of the book the passage is in. (Jonah was swallowed by a "great fish" — Jonah 1:17 — not a whale.)\n' + '• If the question is garbled, incomplete, or does not make sense (speech is auto-transcribed), reply exactly: Could you say that again?\n' + '• If you don\'t know, reply exactly: I don\'t know.\n' + '• Never invent facts. Never make up numbers, dates, names, or quotes.\n' + '• This is a BIBLE STUDY app. Questions about people, places, events, or teachings are about the BIBLE unless clearly not. Example: a question about "Moses\'s father" means the biblical Moses (Amram, Exodus 6:20) — never a random modern name.\n' + '• Clearly non-biblical questions (math, spelling, geography, general knowledge) get normal answers.\n' + '• When Scripture is provided below, base your answer on it and cite the reference (e.g., John 3:16). Do not quote long passages.\n' + '• EVERY reference MUST have a chapter AND a verse (Romans 6:23). NEVER cite a bare book name like "Romans" or "1 John" on its own — only chapter:verse references become tappable links in the app, so a bare book name is useless to the operator. Multiple references are separated by semicolons.\n' + 'Examples:\n' + 'Q: What is 5 plus 5?\nA: 10\n' + 'Q: Who was Moses\'s father?\nA: Amram (Exodus 6:20).\n' + 'Q: What did Jonah get swallowed by?\nA: A great fish, prepared by the LORD (Jonah 1:17).\n' + 'Q: Which two people never died but were taken to heaven?\nA: Enoch (Genesis 5:24) and Elijah (2 Kings 2:11).\n' + 'Q: Who was the oldest person in the Bible?\nA: Methuselah, 969 years (Genesis 5:27).\n' + 'Q: What does the Bible say about unrepented sin?\nA: Sin that is not repented of separates people from God and leads to death and judgment, but God is patient, desiring everyone to come to repentance. Confessing and turning from sin brings forgiveness and cleansing (Romans 6:23; 1 John 1:9; 2 Peter 3:9; Luke 13:3).\n' + 'Q: Where does the Bible talk about sin?\nA: Scripture teaches that sin is breaking God\'s law, that everyone has sinned and falls short of His glory, and that its penalty is death — but God forgives and cleanses those who confess and turn to Christ (Romans 3:23; Romans 6:23; 1 John 1:9; 1 John 3:4).\n' + 'Q: Weird sentence about gift of verse Peter chapter that makes no sense?\nA: Could you say that again?\n' + 'Q: How many countries are in the world?\nA: 195.'; try { window.ASK_SYSTEM_PROMPT = ASK_SYSTEM_PROMPT; } catch (_) {} // Boot pre-warm: prime llama's prompt cache with the static system prompt (1-token generation). // Fire-and-forget; only for the bundled local provider. async function prewarmAskModel() { try { if (window.__askPrewarmed) return; const providers = loadAskProviders(); const active = providers.find(p => p.id === getActiveAskProviderId()) || providers[0]; if (!active || active.provider !== 'llama') return; window.__askPrewarmed = true; await fetch('/api/ask/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'llama', model: 'local', baseUrl: '', apiKey: '', prewarm: true, messages: [{ role: 'system', content: ASK_SYSTEM_PROMPT }, { role: 'user', content: 'Ready?' }] }) }); console.log('[ASK] Prompt cache pre-warmed'); } catch (_) { window.__askPrewarmed = false; } } async function streamAskAnswer(tab, userText, opts) { const tabId = tab.id; const statusDot = document.getElementById(`askStatusDot${tabId}`); const setDotTitle = (text) => { if (statusDot) statusDot.title = text || ''; }; // The streaming target is the topmost assistant bubble in the history list // (which is the placeholder added by handleUserMessage just before this). const findStreamEl = () => { const historyEl = document.getElementById(`askHistory${tabId}`); if (!historyEl) return null; const first = historyEl.querySelector('.ask-bubble-assistant .ask-bubble-content'); return first || null; }; const writeToBubble = (text) => { const el = findStreamEl(); if (el) el.textContent = text; }; // The placeholder assistant message is the last entry in tab.askMessages. const placeholder = (tab.askMessages || []).slice(-1)[0]; const updatePlaceholder = (text) => { if (placeholder && placeholder.role === 'assistant') placeholder.content = text; }; // 1. Try local math first — guaranteed correct, instant, fully offline. const math = tryEvaluateMath(userText); if (math) { const reply = censorProfanity(`${math.expression} = ${math.formatted}`); // Math is deterministic, no need for an artificial typing animation. updatePlaceholder(reply); writeToBubble(reply); if (placeholder) placeholder.source = 'math'; saveAskMessages(tabId, tab.askMessages); setDotTitle('Solved offline (math)'); return; } // 2. Stream from the configured provider via the server proxy. const providers = loadAskProviders(); const activeId = getActiveAskProviderId(); const provider = providers.find(p => p.id === activeId) || providers[0]; if (!provider) { answerEl.textContent = 'No AI provider configured. Open Settings to add one.'; return; } // Send only recent history (last 3 exchanges) so grounding + system prompt always fit the // local model's context window — long services were overflowing it mid-conversation. const messages = (tab.askMessages || []).filter(m => m && (m.role === 'user' || m.role === 'assistant')).map(m => ({ role: m.role, content: m.content })).slice(-6); // Drop the empty placeholder we just inserted, otherwise the model sees an // assistant turn with no content as the most recent message. if (messages.length && messages[messages.length - 1].role === 'assistant' && !messages[messages.length - 1].content) { messages.pop(); } // Scripture grounding: retrieve relevant verses (offline) and append them to the system prompt so // answers are accurate. Purely additive \u2014 the terse answer format below is unchanged. let groundingContext = ''; try { const g = await retrieveGroundingContext(userText); if (g && g.length) { groundingContext = '\nScripture (ESV \u2014 base your answer on this; you may cite the reference):\n' + g.map(v => { let t = String(v.text || '').trim(); if (t.length > 220) t = t.slice(0, 210).replace(/\s+\S*$/, '') + '\u2026'; return v.ref + ' \u2014 ' + t; }).join('\n'); } } catch (_) {} const systemMsg = { role: 'system', content: ASK_SYSTEM_PROMPT + groundingContext }; const payload = { provider: provider.provider, model: provider.model || (provider.provider === 'ollama' ? ASK_DEFAULT_MODEL : 'gpt-4o-mini'), baseUrl: provider.baseUrl || '', apiKey: provider.apiKey || '', messages: [systemMsg, ...messages] }; setDotTitle('Thinking...'); if (statusDot) statusDot.classList.add('thinking'); writeToBubble(''); let assistantText = ''; let aborted = false; try { const ctrl = new AbortController(); if (opts && opts.abortRef) opts.abortRef.current = ctrl; const resp = await fetch('/api/ask/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: ctrl.signal }); if (!resp.body) { const fallback = await resp.text(); assistantText = fallback || ''; const display = censorProfanity(assistantText) || 'No response.'; updatePlaceholder(display); writeToBubble(display); } else { const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); while (true) { const { value, done } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); if (!chunk) continue; assistantText += chunk; // Censor against the full accumulated text so words split // across chunk boundaries are still caught reliably. const display = censorProfanity(assistantText); updatePlaceholder(display); writeToBubble(display); // Intentionally NO auto-scroll \u2014 the newest content is pinned // at the top of the page and the older bubbles scroll down // naturally as the top one grows. } } } catch (err) { if (err && err.name === 'AbortError') { aborted = true; } else { assistantText = (assistantText ? assistantText + '\n\n' : '') + `[Error] ${err && err.message ? err.message : err}`; const display = censorProfanity(assistantText); updatePlaceholder(display); writeToBubble(display); } } if (statusDot) statusDot.classList.remove('thinking'); setDotTitle(aborted ? 'Stopped' : 'Ready'); // Re-render the history so the finished answer's scripture references become tappable // (the streaming bubble is plain text while tokens arrive). try { renderAskHistory(tabId, tab.askMessages); } catch (_) {} // Finalize: persist whatever the placeholder ended up holding. if (placeholder && placeholder.role === 'assistant') { placeholder.source = provider.id; saveAskMessages(tabId, tab.askMessages); } } function initializeAskTab(app, tabId) { const tab = (app.tabs || []).find(t => t.id === tabId); if (!tab) return; if (!Array.isArray(tab.askMessages)) tab.askMessages = loadAskMessages(tabId); renderAskHistory(tabId, tab.askMessages); const micBtn = document.getElementById(`askMicBtn${tabId}`); const statusDot = document.getElementById(`askStatusDot${tabId}`); const clearBtn = document.getElementById(`askClearBtn${tabId}`); if (!micBtn) return; const setDotTitle = (text) => { if (statusDot) statusDot.title = text || ''; }; // Warm-up progress bar for the bundled offline model. Shows only while the model is // loading/starting; once ready it disappears entirely, leaving just the green dot (#19). const askShellEl = document.getElementById(`askShell${tabId}`); if (askShellEl && !document.getElementById(`askLoadingBar${tabId}`)) { const bar = document.createElement('div'); bar.className = 'ask-loading-bar'; bar.id = `askLoadingBar${tabId}`; bar.style.display = 'none'; const fill = document.createElement('div'); fill.className = 'ask-loading-fill'; bar.appendChild(fill); askShellEl.insertBefore(bar, askShellEl.firstChild); } const setLoadingBar = (visible, pct) => { const bar = document.getElementById(`askLoadingBar${tabId}`); if (!bar) return; if (!visible) { bar.style.display = 'none'; return; } bar.style.display = 'block'; const fill = bar.querySelector('.ask-loading-fill'); if (fill) fill.style.width = Math.max(3, Math.min(100, pct || 0)) + '%'; }; // Hydrate provider/model status. The status info is surfaced visually via // the corner dot (color) plus a tooltip on hover \u2014 no on-screen text. const refreshStatus = async () => { try { const providers = loadAskProviders(); const activeId = getActiveAskProviderId(); const provider = providers.find(p => p.id === activeId) || providers[0]; if (!provider) { setDotTitle('No provider configured'); if (statusDot) statusDot.classList.remove('online'); setLoadingBar(false); return; } if (provider.provider === 'llama') { const r = await fetch('/api/ask/health', { cache: 'no-store' }); const j = await r.json().catch(() => ({})); if (j && j.llama) { setDotTitle('Offline AI ready'); if (statusDot) statusDot.classList.add('online'); setLoadingBar(false); prewarmAskModel(); // prime the prompt cache so the FIRST live question is fast } else if (j && j.llamaLoading) { const secs = Number(j.llamaLoadingSeconds) || 0; // Estimate warm-up progress (typical cold load ~90s); cap below 100 until truly ready setLoadingBar(true, Math.min(95, Math.max(5, (secs / 90) * 100))); const health = j.llamaHealthStatus ? ` (${j.llamaHealthStatus})` : ''; const attempt = Number(j.llamaRestartAttempts) > 0 ? `, retry ${j.llamaRestartAttempts}` : ''; if (j.llamaHealthStatus === 'copying-model' || j.llamaPhase === 'copying') { setDotTitle('Preparing local AI files (one-time copy, about 1 GB)...'); } else if (j.llamaHealthStatus === 'waiting-for-port') { setDotTitle(secs > 0 ? `Starting local AI... ${secs}s (loading model into memory)${attempt}` : `Starting local AI... (loading model into memory)${attempt}`); } else { let title = secs > 0 ? `Loading AI model... ${secs}s${health}${attempt}` : `Loading AI model...${health}${attempt}`; if (secs >= 45 && j.llamaRecentLog) { title = `${title} — ${String(j.llamaRecentLog).slice(0, 120)}`; } setDotTitle(title); } if (statusDot) statusDot.classList.remove('online'); setTimeout(refreshStatus, 1500); } else if (j && j.llamaError) { setDotTitle(j.llamaError); if (statusDot) statusDot.classList.remove('online'); setLoadingBar(false); setTimeout(refreshStatus, 4000); } else if (j && j.llamaDirFound === false) { setDotTitle('Local AI files missing from install'); if (statusDot) statusDot.classList.remove('online'); setLoadingBar(false); setTimeout(refreshStatus, 5000); } else { setDotTitle('Local AI starting up...'); if (statusDot) statusDot.classList.remove('online'); setLoadingBar(true, 8); setTimeout(refreshStatus, 2000); } } else if (provider.provider === 'ollama') { const r = await fetch('/api/ask/health', { cache: 'no-store' }); const j = await r.json().catch(() => ({})); setLoadingBar(false); if (j && j.ollama) { const modelLine = (j.models && j.models.length) ? `Ollama ready: ${(j.models[0] || provider.model)}` : 'Ollama ready'; setDotTitle(modelLine); if (statusDot) statusDot.classList.add('online'); } else if (j && j.llama) { setDotTitle('Using bundled local AI'); if (statusDot) statusDot.classList.add('online'); } else { setDotTitle('Ollama not detected'); if (statusDot) statusDot.classList.remove('online'); setTimeout(refreshStatus, 4000); } } else { setDotTitle(`Using ${provider.name || provider.provider}`); if (statusDot) statusDot.classList.add('online'); setLoadingBar(false); } } catch (e) { setDotTitle('Status check failed'); setLoadingBar(false); } }; refreshStatus(); if (clearBtn) { clearBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); tab.askMessages = []; clearAskMessages(tabId); renderAskHistory(tabId, []); }); } // Speech recognition: capture audio with the Web Audio API (works in any // Electron/browser context with microphone permission), encode to a WAV // blob in JavaScript, and POST it to /api/ask/transcribe where the // bundled whisper.cpp binary turns it into text. Fully offline, much // more reliable than Windows SAPI. let listening = false; let abortRef = { current: null }; let recCtx = null; // AudioContext while recording let recStream = null; // MediaStream while recording let recProcessor = null; // ScriptProcessorNode while recording let recSource = null; // MediaStreamSource while recording let recChunks = []; // collected Float32Array PCM chunks let recSampleRate = 16000; const cleanupRecording = () => { try { if (recProcessor) recProcessor.disconnect(); } catch (_) {} try { if (recSource) recSource.disconnect(); } catch (_) {} try { if (recStream) recStream.getTracks().forEach(t => t.stop()); } catch (_) {} try { if (recCtx && recCtx.state !== 'closed') recCtx.close(); } catch (_) {} recProcessor = null; recSource = null; recStream = null; recCtx = null; }; // Encode collected Float32 PCM chunks into a 16-bit mono WAV Blob at the // given sample rate. Whisper accepts any sample rate but performs best at // 16 kHz, which is what we request from the AudioContext. const encodeWavBlob = (chunks, sampleRate) => { const totalSamples = chunks.reduce((n, c) => n + c.length, 0); const buffer = new ArrayBuffer(44 + totalSamples * 2); const view = new DataView(buffer); const writeStr = (offset, str) => { for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i)); }; const dataLen = totalSamples * 2; writeStr(0, 'RIFF'); view.setUint32(4, 36 + dataLen, true); writeStr(8, 'WAVE'); writeStr(12, 'fmt '); view.setUint32(16, 16, true); // PCM chunk size view.setUint16(20, 1, true); // PCM format view.setUint16(22, 1, true); // mono view.setUint32(24, sampleRate, true); // sample rate view.setUint32(28, sampleRate * 2, true); // byte rate view.setUint16(32, 2, true); // block align view.setUint16(34, 16, true); // bits per sample writeStr(36, 'data'); view.setUint32(40, dataLen, true); /* * Peak-normalise on the way out. The operator stands well back from a TV-mounted mic, so the raw * float levels are often very low, and whisper's log-mel front end is level sensitive — a quiet * recording transcribes far worse than a loud one saying the same words. The gain is capped so * near-silence is never blown up into noise that whisper would then try to read as speech. */ let peak = 0; for (const chunk of chunks) { for (let i = 0; i < chunk.length; i++) { const a = chunk[i] < 0 ? -chunk[i] : chunk[i]; if (a > peak) peak = a; } } const gain = (peak > 0.0008 && peak < 0.95) ? Math.min(0.95 / peak, 12) : 1; let offset = 44; for (const chunk of chunks) { for (let i = 0; i < chunk.length; i++) { let s = Math.max(-1, Math.min(1, chunk[i] * gain)); view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); offset += 2; } } return new Blob([buffer], { type: 'audio/wav' }); }; const startRecording = async () => { if (listening) return; try { recStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true, sampleRate: 16000 } }); } catch (err) { setDotTitle('Mic permission denied: ' + (err && err.message ? err.message : err)); return; } try { const Ctor = window.AudioContext || window.webkitAudioContext; recCtx = new Ctor({ sampleRate: 16000 }); recSampleRate = recCtx.sampleRate || 16000; recSource = recCtx.createMediaStreamSource(recStream); recProcessor = recCtx.createScriptProcessor(4096, 1, 1); recChunks = []; recProcessor.onaudioprocess = (ev) => { const data = ev.inputBuffer.getChannelData(0); // Copy because the buffer is reused. recChunks.push(new Float32Array(data)); }; recSource.connect(recProcessor); recProcessor.connect(recCtx.destination); listening = true; micBtn.classList.add('listening'); setDotTitle('Listening...'); } catch (err) { cleanupRecording(); setDotTitle('Mic init error: ' + (err && err.message ? err.message : err)); } }; const stopRecording = async () => { if (!listening) return; const chunks = recChunks; const sr = recSampleRate; cleanupRecording(); listening = false; micBtn.classList.remove('listening'); recChunks = []; const totalSamples = chunks.reduce((n, c) => n + c.length, 0); if (totalSamples < sr * 0.3) { // Less than ~0.3s of audio \u2014 likely a stray tap. setDotTitle('No speech detected'); return; } setDotTitle('Transcribing...'); /* * Put the question bubble on screen the instant the operator stops speaking, before the * transcript exists. Recognition takes a couple of seconds with the accurate model, and staring * at an unchanged screen for that long feels like the app missed the tap. The bubble shows a * listening ellipsis and is replaced by the real words the moment they arrive (or removed if * nothing was caught). */ let provisional = null; try { tab.askMessages = Array.isArray(tab.askMessages) ? tab.askMessages : []; provisional = { role: 'user', content: '…', ts: Date.now(), pending: true }; tab.askMessages.push(provisional); renderAskHistory(tabId, tab.askMessages); } catch (_) {} const dropProvisional = () => { try { if (!provisional) return; const i = tab.askMessages.indexOf(provisional); if (i !== -1) tab.askMessages.splice(i, 1); provisional = null; } catch (_) {} }; try { const wavBlob = encodeWavBlob(chunks, sr); const resp = await fetch('/api/ask/transcribe', { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wavBlob }); const j = await resp.json().catch(() => ({})); if (j && j.ok && j.text) { dropProvisional(); // handleUserMessage pushes the real question const cleaned = censorProfanity(j.text); handleUserMessage(cleaned); setDotTitle('Ready'); } else { dropProvisional(); renderAskHistory(tabId, tab.askMessages); setDotTitle((j && j.error) || 'Transcription failed'); } } catch (e) { dropProvisional(); try { renderAskHistory(tabId, tab.askMessages); } catch (_) {} setDotTitle('Transcription error: ' + (e && e.message ? e.message : e)); } }; micBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (abortRef.current) { try { abortRef.current.abort(); } catch (_) {} abortRef.current = null; } if (listening) stopRecording(); else startRecording(); }); async function handleUserMessage(text) { if (!text) return; const cleaned = censorProfanity(text); tab.askMessages = Array.isArray(tab.askMessages) ? tab.askMessages : []; tab.askMessages.push({ role: 'user', content: cleaned, ts: Date.now() }); // Add an empty placeholder bubble for the assistant so the user's // question gets pinned at the top of the conversation immediately // and the streaming reply can be written into the bubble in place // (older bubbles naturally shift down as it grows). tab.askMessages.push({ role: 'assistant', content: '', ts: Date.now() }); renderAskHistory(tabId, tab.askMessages); try { await streamAskAnswer(tab, cleaned, { abortRef }); } catch (err) { console.error('[ASK] streamAskAnswer failed', err); } } } // Expose for cross-module use (settings page can update providers and the active tab will re-read). try { window.AskTab = window.AskTab || {}; window.AskTab.loadProviders = loadAskProviders; window.AskTab.getActiveProviderId = getActiveAskProviderId; window.AskTab.setActiveProviderId = setActiveAskProviderId; } catch (_) {} export default class BibleApp { constructor() { window.bibleApp = this; // Ensure global reference is set early this.tabs = []; this.activeTabId = null; this.translations = getVisibleTranslationCodes(); this._announcementCache = { ts: 0, items: [] }; this._announcementTimers = {}; // Verses missing by translation; only hide for the current translation this.missingVersesByTranslation = { ESV: new Set([ 'Matthew|12|47','Matthew|17|21','Matthew|18|11','Matthew|23|14', 'Mark|7|16','Mark|9|44','Mark|9|46','Mark|11|26','Mark|15|28', 'Luke|17|36','Luke|23|17','John|5|4','Acts|8|37','Acts|15|34', 'Acts|24|7','Acts|28|29','Romans|16|24' ]), NIV: new Set([ 'Matthew|17|21','Matthew|18|11','Matthew|23|14', 'Mark|7|16','Mark|9|44','Mark|9|46','Mark|11|26','Mark|15|28', 'Luke|17|36','Luke|23|17','John|5|4','Acts|8|37','Acts|15|34', 'Acts|24|7','Acts|28|29','Romans|16|24' ]), NLT: new Set([ 'Matthew|17|21','Matthew|18|11','Matthew|23|14', 'Mark|7|16','Mark|9|44','Mark|9|46','Mark|11|26','Mark|15|28', 'Luke|17|36','Luke|23|17','John|5|4','Acts|8|37','Acts|15|34', 'Acts|24|7','Acts|28|29','Romans|16|24' ]), MSG: new Set([ 'Matthew|17|21','Matthew|18|11','Matthew|23|14', 'Mark|7|16','Mark|9|44','Mark|9|46','Mark|11|26','Mark|15|28', 'Luke|17|36','Luke|23|17','Acts|8|37','Acts|15|34', 'Acts|24|7','Acts|28|29','Romans|16|24' ]), KJV: new Set(), // none missing NKJV: new Set() // none missing }; this.init(); } // Soft reset: rebuild default tabs without clearing stored annotations resetToStartup(force = false) { try { // Guard against accidental calls (e.g., stray clicks during tab close) try { if (!force && !window._resetAppRequested) { console.warn('[RESET] Ignored resetToStartup without explicit request'); return; } } catch (_) {} // PRESERVE PRAYER CONTENT: Save all prayer content before reset const savedPrayerContent = {}; if (this.tabs) { this.tabs.forEach(tab => { if (tab.mode === 'prayer' && tab.id) { try { const content = getPrayerContentSnapshot(tab); if (content && content.trim()) { // Use tab ID as key, but we'll need to match it to new prayer tab savedPrayerContent[tab.id] = content; persistPrayerContent(tab.id, content); } } catch (_) {} } }); } // Also save all prayer content from localStorage by scanning all keys for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith('prayer-content-')) { try { const content = localStorage.getItem(key); if (content) { savedPrayerContent[key] = content; } } catch (_) {} } } const tabContent = document.querySelector('.tab-content'); if (tabContent) tabContent.innerHTML = ''; this.tabs = []; this.activeTabId = null; // Recreate default tabs (announcements, graphics, whiteboard, notes, prayer, ask, bible) runAutomaticOperation(() => { this.createAnnouncementsTab(); this.createGraphicsTab(); this.createMapsTab(); this.createWhiteboardTab(); this.createNotesTab(); this.createPrayerTab(); this.createAskTab(); this.createPPSyncTab(); this.createNewTab(); }); // Ensure pen color resets to the correct default for the active tab setTimeout(() => { try { __prevPenColorBeforeNotes = null; __prevPenColorBeforeWhiteboard = null; const activeTab = this.tabs.find(t => t.id === this.activeTabId); if (!activeTab) return; if (activeTab.mode === 'whiteboard') { penColor = '#fefefe'; } else if (activeTab.mode === 'notes') { penColor = '#000000'; } else if (activeTab.mode === 'bible' || activeTab.mode === 'alpha') { penColor = '#ffff00'; } window.penColor = penColor; if (typeof updateToolUI === 'function') updateToolUI(); const penBtn = window.penBtn || document.getElementById('penBtn'); if (penBtn) { penBtn.style.setProperty('--pen-color', penColor); penBtn.style.color = penColor; } } catch (_) {} }, 80); // RESTORE PRAYER CONTENT: Restore saved prayer content after tabs are recreated setTimeout(() => { try { // Find the prayer tab that was just created const prayerTab = this.tabs.find(t => t.mode === 'prayer'); if (prayerTab) { // Restore all saved prayer content - combine all saved content const savedKeys = Object.keys(savedPrayerContent); if (savedKeys.length > 0) { // Combine all prayer content (in case there were multiple prayer tabs) // Use the longest/most complete content, or combine them let contentToRestore = ''; savedKeys.forEach(key => { const content = savedPrayerContent[key]; if (content && content.length > contentToRestore.length) { contentToRestore = content; } }); if (contentToRestore) { prayerTab.prayerContent = contentToRestore; localStorage.setItem(`prayer-content-${prayerTab.id}`, contentToRestore); // Update the prayer text element if it exists setTimeout(() => { const prayerTextEl = document.getElementById(`prayerText${prayerTab.id}`); if (prayerTextEl) { // Check if content is HTML or plain text if (contentToRestore.includes('<') && contentToRestore.includes('>')) { prayerTextEl.innerHTML = contentToRestore; } else { prayerTextEl.innerHTML = contentToRestore.replace(/\n/g, '
'); } console.log('[RESET] Restored prayer content after reset'); } }, 200); } } } } catch (e) { console.warn('[RESET] Failed to restore prayer content:', e); } }, 100); // After tabs are recreated, force annotations/canvas redraw for the active Bible tab setTimeout(() => { const tabId = this.activeTabId; try { setupAnnotationForActiveTab(); } catch (_) {} try { loadAnnotations(tabId); } catch (_) {} try { ensureNonZeroCanvas(tabId); } catch (_) {} // Use multiple frames/timeouts to ensure redraw without user interaction const redraw = () => { try { updateVisibleCanvas(tabId); } catch (_) {} try { redrawAllStrokes(tabId); } catch (_) {} try { ensurePenDrawingReady(tabId); } catch (_) {} try { ensureCanvasAndPenReady(tabId); } catch (_) {} }; redraw(); setTimeout(redraw, 80); setTimeout(redraw, 200); setTimeout(redraw, 400); }, 300); } catch (e) { console.error('[RESET] Failed to reset app:', e); } finally { try { window._resetAppRequested = false; } catch (_) {} } } // Generate a unique tab ID, avoiding any collision with existing tabs generateUniqueTabId(preferredBase = Date.now()) { let id = Number(preferredBase) || Date.now(); const existing = new Set(this.tabs.map(t => String(t.id))); while (existing.has(String(id))) id++; return id; } // Prevent top-UI scroll leaks, but allow deliberate 2-finger tab-strip panning. installTopUIScrollBlockers() { try { const toolbar = document.querySelector('.toolbar'); const tabBar = (toolbar && toolbar.querySelector) ? toolbar.querySelector('.tab-bar') : document.querySelector('.tab-bar'); const blockScroll = (ev) => { try { ev.preventDefault(); } catch (_) {} }; const blockToolbarScroll = (ev) => { try { const t = ev && ev.target; // Let tab-bar handlers manage their own 2-finger pan behavior. if (t && t.closest && t.closest('.tab-bar')) return; ev.preventDefault(); } catch (_) {} }; // Toolbar: keep strict blocking to avoid page panning. if (toolbar && !toolbar._topScrollBlockersInstalled) { try { toolbar.style.setProperty('touch-action', 'none', 'important'); toolbar.style.setProperty('overscroll-behavior', 'none', 'important'); } catch (_) {} toolbar.addEventListener('wheel', blockToolbarScroll, { passive: false, capture: true }); toolbar.addEventListener('gesturestart', blockToolbarScroll, { passive: false, capture: true }); toolbar.addEventListener('gesturechange', blockToolbarScroll, { passive: false, capture: true }); toolbar.addEventListener('touchmove', blockToolbarScroll, { passive: false, capture: true }); toolbar._topScrollBlockersInstalled = true; } // Tab bar: convert 2-finger gestures into horizontal tab-strip panning. if (tabBar && !tabBar._topScrollBlockersInstalled) { try { tabBar.style.setProperty('touch-action', 'none', 'important'); tabBar.style.setProperty('overscroll-behavior', 'none', 'important'); } catch (_) {} const browserTabs = (toolbar && toolbar.querySelector) ? toolbar.querySelector('.browser-tabs') : document.querySelector('.browser-tabs'); const isEventInTabStripZone = (ev) => { try { const t = ev && ev.target; if (t && t.closest && (t.closest('.tab-bar') || t.closest('.browser-tabs'))) return true; const touch = (ev && ev.touches && ev.touches[0]) || (ev && ev.changedTouches && ev.changedTouches[0]); if (!touch) return false; const tx = Number(touch.clientX); const ty = Number(touch.clientY); const barRect = tabBar.getBoundingClientRect(); // Accept a small tolerance around the row so near-edge touches still count. if (tx >= (barRect.left - 12) && tx <= (barRect.right + 12) && ty >= (barRect.top - 10) && ty <= (barRect.bottom + 10)) return true; if (browserTabs) { const tabsRect = browserTabs.getBoundingClientRect(); if (tx >= (tabsRect.left - 12) && tx <= (tabsRect.right + 12) && ty >= (tabsRect.top - 10) && ty <= (tabsRect.bottom + 10)) return true; } } catch (_) {} return false; }; const isPointerInTabStripZone = (ev) => { try { const t = ev && ev.target; if (t && t.closest && (t.closest('.tab-bar') || t.closest('.browser-tabs'))) return true; const px = Number(ev && ev.clientX); const py = Number(ev && ev.clientY); if (!Number.isFinite(px) || !Number.isFinite(py)) return false; const barRect = tabBar.getBoundingClientRect(); if (px >= (barRect.left - 12) && px <= (barRect.right + 12) && py >= (barRect.top - 10) && py <= (barRect.bottom + 10)) return true; if (browserTabs) { const tabsRect = browserTabs.getBoundingClientRect(); if (px >= (tabsRect.left - 12) && px <= (tabsRect.right + 12) && py >= (tabsRect.top - 10) && py <= (tabsRect.bottom + 10)) return true; } } catch (_) {} return false; }; const canScrollTabs = () => { try { return tabBar.scrollWidth > (tabBar.clientWidth + 2); } catch (_) { return false; } }; const onTabBarWheel = (ev) => { try { tabScrollDebug('wheel', `dx=${Math.round(ev.deltaX || 0)}`, `dy=${Math.round(ev.deltaY || 0)}`); const delta = Math.abs(ev.deltaX) >= Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; if (canScrollTabs()) { const before = tabBar.scrollLeft; tabBar.scrollLeft += delta; tabScrollDebug('wheel-scroll', `from=${Math.round(before)}`, `to=${Math.round(tabBar.scrollLeft)}`); } ev.preventDefault(); } catch (_) {} }; let twoFingerActive = false; let lastCenterX = 0; const getTwoFingerCenterX = (touchList) => { if (!touchList || touchList.length < 2) return null; return (touchList[0].clientX + touchList[1].clientX) / 2; }; const onTabBarTouchStart = (ev) => { if (!isEventInTabStripZone(ev)) return; const cx = getTwoFingerCenterX(ev.touches); if (cx == null) return; twoFingerActive = true; lastCenterX = cx; try { window.__tabStripTwoFingerPanLock = true; } catch (_) {} tabScrollDebug('touchstart-2f', `touches=${ev.touches ? ev.touches.length : 0}`); }; const onTabBarTouchMove = (ev) => { if (!isEventInTabStripZone(ev)) return; const cx = getTwoFingerCenterX(ev.touches); if (cx == null) { twoFingerActive = false; return; } if (!twoFingerActive) { twoFingerActive = true; lastCenterX = cx; return; } const dx = cx - lastCenterX; lastCenterX = cx; if (canScrollTabs()) { const before = tabBar.scrollLeft; tabBar.scrollLeft -= dx; tabScrollDebug('touchmove-2f', `dx=${Math.round(dx)}`, `from=${Math.round(before)}`, `to=${Math.round(tabBar.scrollLeft)}`); } try { ev.preventDefault(); ev.stopPropagation(); } catch (_) {} }; const onTabBarTouchEnd = (ev) => { if (!ev.touches || ev.touches.length < 2) { twoFingerActive = false; try { window.__tabStripTwoFingerPanLock = false; } catch (_) {} } }; // Windows touch devices frequently dispatch multi-touch via Pointer Events. const activeTouchPointers = new Map(); const getPointerCenterX = () => { const values = Array.from(activeTouchPointers.values()); if (values.length < 2) return null; return (values[0].x + values[1].x) / 2; }; let pointerTwoFingerActive = false; let pointerLastCenterX = 0; const onTabBarPointerDown = (ev) => { if (!ev || ev.pointerType !== 'touch') return; if (!isPointerInTabStripZone(ev)) return; activeTouchPointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); const cx = getPointerCenterX(); if (cx == null) return; pointerTwoFingerActive = true; pointerLastCenterX = cx; try { window.__tabStripTwoFingerPanLock = true; } catch (_) {} tabScrollDebug('pointerdown-touch', `id=${ev.pointerId}`, `count=${activeTouchPointers.size}`); }; const onTabBarPointerMove = (ev) => { if (!ev || ev.pointerType !== 'touch') return; if (!isPointerInTabStripZone(ev)) return; if (!activeTouchPointers.has(ev.pointerId)) return; activeTouchPointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); const cx = getPointerCenterX(); if (cx == null) { pointerTwoFingerActive = false; return; } if (!pointerTwoFingerActive) { pointerTwoFingerActive = true; pointerLastCenterX = cx; return; } const dx = cx - pointerLastCenterX; pointerLastCenterX = cx; if (canScrollTabs()) { const before = tabBar.scrollLeft; tabBar.scrollLeft -= dx; tabScrollDebug('pointermove-touch', `dx=${Math.round(dx)}`, `from=${Math.round(before)}`, `to=${Math.round(tabBar.scrollLeft)}`); } try { ev.preventDefault(); ev.stopPropagation(); } catch (_) {} }; const onTabBarPointerUp = (ev) => { if (!ev) return; activeTouchPointers.delete(ev.pointerId); if (activeTouchPointers.size < 2) { pointerTwoFingerActive = false; try { window.__tabStripTwoFingerPanLock = false; } catch (_) {} } }; tabBar.addEventListener('wheel', onTabBarWheel, { passive: false, capture: true }); tabBar.addEventListener('gesturestart', blockScroll, { passive: false, capture: true }); tabBar.addEventListener('gesturechange', blockScroll, { passive: false, capture: true }); tabBar.addEventListener('touchstart', onTabBarTouchStart, { passive: true, capture: true }); tabBar.addEventListener('touchmove', onTabBarTouchMove, { passive: false, capture: true }); tabBar.addEventListener('touchend', onTabBarTouchEnd, { passive: true, capture: true }); tabBar.addEventListener('touchcancel', onTabBarTouchEnd, { passive: true, capture: true }); tabBar.addEventListener('pointerdown', onTabBarPointerDown, { passive: true, capture: true }); tabBar.addEventListener('pointermove', onTabBarPointerMove, { passive: false, capture: true }); tabBar.addEventListener('pointerup', onTabBarPointerUp, { passive: true, capture: true }); tabBar.addEventListener('pointercancel', onTabBarPointerUp, { passive: true, capture: true }); // Hard fallback: window-capture pointer handler for touch panning on tab bar. // This wins even when child tabs use pointer capture for their own gestures. if (!tabBar._windowTwoFingerPanInstalled) { const activeWinPointers = new Map(); let winPanActive = false; let winLastCenterX = 0; let winTouchPanActive = false; let winTouchLastCenterX = 0; const getWinCenterX = () => { const pts = Array.from(activeWinPointers.values()); if (pts.length < 2) return null; return (pts[0].x + pts[1].x) / 2; }; const getWinTouchCenterX = (touches) => { if (!touches || touches.length < 2) return null; return (touches[0].clientX + touches[1].clientX) / 2; }; const onWinPointerDown = (ev) => { if (!ev || ev.pointerType !== 'touch') return; if (!isPointerInTabStripZone(ev)) return; activeWinPointers.set(ev.pointerId, { x: ev.clientX }); const cx = getWinCenterX(); if (cx == null) return; winPanActive = true; winLastCenterX = cx; tabScrollDebug('win-pointerdown-touch', `id=${ev.pointerId}`, `count=${activeWinPointers.size}`); }; const onWinPointerMove = (ev) => { if (!ev || ev.pointerType !== 'touch') return; if (!activeWinPointers.has(ev.pointerId)) return; activeWinPointers.set(ev.pointerId, { x: ev.clientX }); const cx = getWinCenterX(); if (cx == null) { winPanActive = false; return; } if (!winPanActive) { winPanActive = true; winLastCenterX = cx; return; } const dx = cx - winLastCenterX; winLastCenterX = cx; if (canScrollTabs()) { const before = tabBar.scrollLeft; tabBar.scrollLeft -= dx; tabScrollDebug('win-pointermove-touch', `dx=${Math.round(dx)}`, `from=${Math.round(before)}`, `to=${Math.round(tabBar.scrollLeft)}`); } try { ev.preventDefault(); ev.stopPropagation(); } catch (_) {} }; const onWinPointerUp = (ev) => { if (!ev) return; activeWinPointers.delete(ev.pointerId); if (activeWinPointers.size < 2) { winPanActive = false; } }; const onWinTouchStart = (ev) => { if (!isEventInTabStripZone(ev)) return; const cx = getWinTouchCenterX(ev.touches); if (cx == null) return; winTouchPanActive = true; winTouchLastCenterX = cx; try { window.__tabStripTwoFingerPanLock = true; } catch (_) {} tabScrollDebug('win-touchstart-2f', `touches=${ev.touches ? ev.touches.length : 0}`); }; const onWinTouchMove = (ev) => { if (!isEventInTabStripZone(ev)) return; const cx = getWinTouchCenterX(ev.touches); if (cx == null) { winTouchPanActive = false; return; } if (!winTouchPanActive) { winTouchPanActive = true; winTouchLastCenterX = cx; return; } const dx = cx - winTouchLastCenterX; winTouchLastCenterX = cx; if (canScrollTabs()) { const before = tabBar.scrollLeft; tabBar.scrollLeft -= dx; tabScrollDebug('win-touchmove-2f', `dx=${Math.round(dx)}`, `from=${Math.round(before)}`, `to=${Math.round(tabBar.scrollLeft)}`); } try { if (ev.cancelable) ev.preventDefault(); ev.stopPropagation(); } catch (_) {} }; const onWinTouchEnd = (ev) => { if (!ev.touches || ev.touches.length < 2) { winTouchPanActive = false; try { window.__tabStripTwoFingerPanLock = false; } catch (_) {} } }; window.addEventListener('pointerdown', onWinPointerDown, { passive: true, capture: true }); window.addEventListener('pointermove', onWinPointerMove, { passive: false, capture: true }); window.addEventListener('pointerup', onWinPointerUp, { passive: true, capture: true }); window.addEventListener('pointercancel', onWinPointerUp, { passive: true, capture: true }); window.addEventListener('touchstart', onWinTouchStart, { passive: true, capture: true }); window.addEventListener('touchmove', onWinTouchMove, { passive: false, capture: true }); window.addEventListener('touchend', onWinTouchEnd, { passive: true, capture: true }); window.addEventListener('touchcancel', onWinTouchEnd, { passive: true, capture: true }); tabBar._windowTwoFingerPanInstalled = true; } tabBar._topScrollBlockersInstalled = true; } } catch (_) {} } initializeToolRail() { const toolRail = document.getElementById('toolRail'); const toolRailHandle = document.getElementById('toolRailHandle'); const toolIndicator = document.getElementById('toolIndicator'); if (!toolRail) return; // Anchor rail and handle to the toolbar's right edge area (fixed overlay) const toolbar = document.querySelector('.toolbar'); const positionRail = () => { if (!toolbar) return; const toolbarRect = toolbar.getBoundingClientRect(); const tabBar = toolbar.querySelector('.tab-bar'); const refRect = tabBar ? tabBar.getBoundingClientRect() : toolbarRect; // Center the tool rail vertically within the toolbar and nudge it down slightly const verticalNudge = 2; // fine-tuned downward nudge for TV layout const topBase = Math.round(toolbarRect.top + (toolbarRect.height - refRect.height) / 2); // Match the tool rail exactly to the tabs row (height) and centered top toolRail.style.top = `${Math.max(0, topBase + verticalNudge)}px`; toolRail.style.height = `${Math.round(refRect.height)}px`; toolRail.style.paddingTop = '0px'; toolRail.style.paddingBottom = '0px'; toolRail.style.right = '0px'; // Align the handle to the same row as tabs const handleHeight = Math.max(28, Math.round(refRect.height)); if (toolRailHandle) { toolRailHandle.style.height = `${handleHeight}px`; toolRailHandle.style.top = `${Math.max(0, topBase + verticalNudge)}px`; } // Enlarge tool indicator for TV touch targets and align vertically if (toolIndicator) { try { toolIndicator.style.minWidth = `${handleHeight}px`; toolIndicator.style.height = `${handleHeight}px`; toolIndicator.style.display = 'flex'; toolIndicator.style.alignItems = 'center'; toolIndicator.style.justifyContent = 'center'; toolIndicator.style.touchAction = 'manipulation'; toolIndicator.style.userSelect = 'none'; toolIndicator.style.webkitUserSelect = 'none'; toolIndicator.style.webkitTapHighlightColor = 'transparent'; // Make it obvious this is tappable toolIndicator.style.cursor = 'pointer'; } catch (_) {} } // Invisible edge marker for potential touch area/hints const edge = document.getElementById('toolRailEdge'); if (edge) { edge.style.top = `${Math.round((topBase + verticalNudge) + refRect.height / 2)}px`; edge.style.right = '0px'; } }; positionRail(); // Recalculate after layout settles to avoid race with fonts/sizing requestAnimationFrame(() => positionRail()); setTimeout(positionRail, 60); setTimeout(positionRail, 200); // Throttle resize/scroll handlers with rAF to reduce layout thrash let railRaf = null; const scheduleRailPosition = () => { if (railRaf != null) return; railRaf = requestAnimationFrame(() => { railRaf = null; positionRail(); }); }; window.addEventListener('resize', scheduleRailPosition); window.addEventListener('scroll', scheduleRailPosition, { passive: true }); let isOpen = false; let pointerDown = false; let startX = 0; let lastX = 0; let autoHideTimer = null; const clearAutoHide = () => { if (autoHideTimer) { clearTimeout(autoHideTimer); autoHideTimer = null; } }; const scheduleAutoHide = () => { clearAutoHide(); autoHideTimer = setTimeout(() => { // Don't auto-hide if user is adjusting color/size UI if (window.penSizeAdjustActive) return; closeRail(); }, 5000); }; const openRail = () => { if (isOpen) return; toolRail.classList.add('open'); try { toolRailHandle.classList.add('hidden'); } catch (_) {} isOpen = true; try { const ind = document.getElementById('toolIndicator'); if (ind) ind.classList.add('hidden'); } catch(_) {} // Guard against accidental tool selection from the same tap that opened the rail try { window.toolRailInputGuardUntil = Date.now() + 350; } catch (_) {} scheduleAutoHide(); }; const closeRail = () => { if (!isOpen) return; toolRail.classList.remove('open'); try { toolRailHandle.classList.remove('hidden'); } catch (_) {} isOpen = false; clearAutoHide(); try { const ind = document.getElementById('toolIndicator'); if (ind) ind.classList.remove('hidden'); } catch(_) {} // Also close any open color/size popups when the toolbar closes try { const penColor = document.getElementById('penColorDropdown'); const highColor = document.getElementById('highlighterColorDropdown'); const sizePopup = document.getElementById('penSizeSliderPopup'); if (penColor) penColor.classList.remove('show'); if (highColor) highColor.classList.remove('show'); if (sizePopup) sizePopup.classList.remove('show'); } catch (_) {} }; // Make the pen/highlighter indicator act as the opener for the tool rail if (toolIndicator) { const openFromIndicator = (e) => { try { e.preventDefault(); } catch (_) {} try { e.stopPropagation(); } catch (_) {} openRail(); }; // Pointer events cover touch/pen/mouse on Windows touch TVs toolIndicator.addEventListener('pointerdown', openFromIndicator, { passive: false }); toolIndicator.addEventListener('click', openFromIndicator, { passive: false }); } // Edge handle: swipe/drag from right edge to open const onPointerDown = (e) => { pointerDown = true; startX = e.clientX || (e.touches && e.touches[0]?.clientX) || 0; lastX = startX; // lock page scroll during gesture try { document.body.style.overflowX = 'hidden'; } catch (_) {} }; const onPointerMove = (e) => { if (!pointerDown) return; const x = e.clientX || (e.touches && e.touches[0]?.clientX) || 0; lastX = x; // If user drags left more than threshold, open if (startX - x > 18) { openRail(); pointerDown = false; try { document.body.style.overflowX = ''; } catch (_) {} } }; const onPointerUp = () => { // Treat a quick tap (little/no movement) as an open gesture if (pointerDown) { const moved = Math.abs(startX - lastX); if (moved < 6) { openRail(); } } pointerDown = false; try { document.body.style.overflowX = ''; } catch (_) {} }; // Close only when tapping/clicking on the reading canvas area (not any click to the left), // and only if the current tool is Select. Drawing tools should keep the toolbar open. document.addEventListener('pointerdown', (e) => { const withinRail = toolRail.contains(e.target) || (toolRailHandle && toolRailHandle.contains && toolRailHandle.contains(e.target)); if (withinRail) return; // Close only if the pointerdown originated in a reading-area or its annotation canvas const target = e.target; const isInSizeOrColorUI = target.closest && ( target.closest('.size-slider-popup') || target.closest('#penColorDropdown') || target.closest('#highlighterColorDropdown') || target.closest('#penSizeSliderPopup') || target.closest('#penSizeSlider') ); if (isInSizeOrColorUI) return; // don't close when adjusting sliders or colors const isReadingArea = target.closest && (target.closest('.reading-area') || target.classList?.contains('annotation-canvas') || target.classList?.contains('annotation-overlay-canvas')); // BUGFIX: if user just adjusted color/size and quickly taps canvas, keep rail open briefly if (isReadingArea && (window.currentTool === 'select')) { if (window.recentToolAdjustment && Date.now() - window.recentToolAdjustment < 400) return; closeRail(); } }, { passive: true }); // Also ignore pointerup closing if it happens on the size/color UI document.addEventListener('pointerup', (e) => { const target = e.target; const isInSizeOrColorUI = target.closest && ( target.closest('.size-slider-popup') || target.closest('#penSizeSliderPopup') || target.closest('#penSizeSlider') || target.closest('#penColorDropdown') || target.closest('#highlighterColorDropdown') ); if (isInSizeOrColorUI) { e.stopPropagation(); // mark recent adjustment to prevent immediate auto-close on next canvas tap window.recentToolAdjustment = Date.now(); return; } }, { passive: true }); // Note: Do NOT auto-close on tool button click to allow size/color adjustments. // The rail will auto-close on canvas interaction (reading-area) or when drawing starts. // Pointer events on the invisible edge handle if (toolRailHandle) { toolRailHandle.addEventListener('pointerdown', (e) => { clearAutoHide(); onPointerDown(e); }, { passive: true }); toolRailHandle.addEventListener('pointermove', (e) => { scheduleAutoHide(); onPointerMove(e); }, { passive: true }); toolRailHandle.addEventListener('pointerup', (e) => { scheduleAutoHide(); onPointerUp(e); }, { passive: true }); toolRailHandle.addEventListener('pointercancel', (e) => { scheduleAutoHide(); onPointerUp(e); }, { passive: true }); // Touch fallbacks toolRailHandle.addEventListener('touchstart', (e) => { e.preventDefault(); clearAutoHide(); onPointerDown(e); }, { passive: false }); toolRailHandle.addEventListener('touchmove', (e) => { e.preventDefault(); scheduleAutoHide(); onPointerMove(e); }, { passive: false }); toolRailHandle.addEventListener('touchend', (e) => { scheduleAutoHide(); onPointerUp(e); }, { passive: true }); // Simple tap/click on the handle should open the tool rail too toolRailHandle.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openRail(); }); } // Also allow swipe-to-close by dragging the rail itself slightly right let railDown = false; let railStartX = 0; toolRail.addEventListener('pointerdown', (e) => { railDown = true; railStartX = e.clientX || 0; clearAutoHide(); }); toolRail.addEventListener('pointermove', (e) => { if (!railDown || !isOpen) return; const dx = (e.clientX || 0) - railStartX; // Do not close if the gesture starts or moves within size/color UI const target = e.target; const isInSizeOrColorUI = target.closest && ( target.closest('.size-slider-popup') || target.closest('#penSizeSliderPopup') || target.closest('#penSizeSlider') || target.closest('#penColorDropdown') || target.closest('#highlighterColorDropdown') ); if (dx > 12 && !isInSizeOrColorUI && !window.penSizeAdjustActive) closeRail(); else scheduleAutoHide(); }); toolRail.addEventListener('pointerup', () => { railDown = false; scheduleAutoHide(); }); toolRail.addEventListener('pointercancel', () => { railDown = false; scheduleAutoHide(); }); // Expose controls for debugging window.openToolRail = openRail; window.closeToolRail = closeRail; } init() { // Initialize LocalMediaHandler early to ensure it's available if (!this.localMediaHandler) { console.log('[DEBUG] Creating LocalMediaHandler during app init'); this.localMediaHandler = new LocalMediaHandler(); window.localMediaHandler = this.localMediaHandler; } // Create initial tabs automatically without recording them as user operations: // 1) Announcements tab (leftmost; image-first view) // 2) Graphics tab (image carousel from Graphics/ directory) // 3) Whiteboard tab (not active) // 4) Notes tab // 5) Prayer tab // 6) Ask tab (AI assistant) // 7) First Bible tab (active on load) runAutomaticOperation(() => { this.createAnnouncementsTab(); this.createGraphicsTab(); this.createMapsTab(); this.createWhiteboardTab(); this.createNotesTab(); this.createPrayerTab(); this.createAskTab(); this.createPPSyncTab(); this.createNewTab(); }); // Initialize tool UI after app is set up setTimeout(initializeToolUI, 100); // Force pen ready for initial Bible tab after first render setTimeout(() => { forcePenReadyForTab(this.activeTabId); ensurePenDrawingReady(this.activeTabId); ensureNonZeroCanvas(this.activeTabId); }, 300); setTimeout(() => ensureCanvasAndPenReady(this.activeTabId), 500); // Initialize slide-out tool rail interactions setTimeout(() => { try { this.initializeToolRail(); } catch (_) {} }, 150); // Disable any scrolling/panning on top UI (toolbar and tab bar) setTimeout(() => { try { this.installTopUIScrollBlockers(); } catch (_) {} }, 0); // ROBUST FIX: Ensure proper initialization with comprehensive timing setTimeout(() => { this.ensureTabFullyInitialized(); }, 100); // AGGRESSIVE MONITORING: Check and fix select tool cursor every few seconds this.startSelectToolMonitoring(); // Ensure scripture reference taps/clicks work globally like the test button try { setupGlobalScriptureReferenceHandlers(); } catch (_) {} // Install global delegated handlers for header nav buttons to ensure reliability across restores try { this.installGlobalHeaderButtonHandlers(); } catch (_) {} } // NEW: Comprehensive tab initialization that works for both startup and tab switching ensureTabFullyInitialized(tabId = null) { const targetTab = tabId ? this.tabs.find(t => t.id === tabId) : this.tabs[0]; if (!targetTab) { console.log('[DEBUG] No tab found for initialization'); return; } console.log('[DEBUG] Ensuring full initialization for tab:', targetTab.id, 'mode:', targetTab.mode); if (targetTab.mode === 'media') { // Apply the EXACT same sequence that works during tab switching this.initializeMediaTab(targetTab.id); } else { // For Bible tabs, ensure annotation setup setTimeout(() => { setupAnnotationForActiveTab(); }, 50); } } // Global delegated handlers for Top, Bottom, and -5/+5 buttons so they always work installGlobalHeaderButtonHandlers() { if (this._globalHeaderHandlersInstalled) return; const onNav = (delta, tabId) => { try { const ra = document.getElementById(`readingArea${tabId}`); const btEl = document.getElementById(`bibleText${tabId}`); if (!ra || !btEl) return; let headerOffset = 0; try { const header = document.getElementById(`chapterHeader${tabId}`); 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; let maxVerses = 1; 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; } maxVerses = Math.max(maxVerses, v); } }); const dest = Math.max(1, Math.min(maxVerses, currentVerse + delta)); const tab = this.tabs.find(t => t.id === tabId); if (!tab) return; tab._scrollToVerse = dest; this.autoScrollToSelectedVerse(tabId); } catch (_) {} }; const onTop = (tabId) => { try { const ra = document.getElementById(`readingArea${tabId}`); if (ra) { ra.scrollTop = 0; const bt = document.getElementById(`bibleText${tabId}`); if (bt) { bt.classList.remove('smooth-fade-in'); void bt.offsetWidth; bt.classList.add('smooth-fade-in'); } } } catch (_) {} }; const onBottom = (tabId) => { try { const bt = document.getElementById(`bibleText${tabId}`); const ra = document.getElementById(`readingArea${tabId}`); if (!bt || !ra) return; let lastVerseNum = null; bt.querySelectorAll('.verse[data-verse]').forEach(el => { const v = parseInt(el.getAttribute('data-verse') || '0', 10); if (!isNaN(v)) lastVerseNum = (lastVerseNum == null) ? v : Math.max(lastVerseNum, v); }); if (typeof lastVerseNum === 'number' && !isNaN(lastVerseNum)) { const tab = this.tabs.find(t => t.id === tabId); if (!tab) return; tab._scrollToVerse = lastVerseNum; this.autoScrollToSelectedVerse(tabId); } else { const maxScroll = Math.max(0, (ra.scrollHeight - ra.clientHeight) || 0); ra.scrollTop = maxScroll; } } catch (_) {} }; const handler = (e) => { const el = e.target && e.target.closest && e.target.closest('.header-top-btn, .header-down-btn, .header-nav-btn'); if (!el) return; try { e.preventDefault(); e.stopPropagation(); } catch (_) {} const tabId = parseInt(el.getAttribute('data-tab-id') || String(this.activeTabId), 10); if (!tabId) return; if (el.classList.contains('header-top-btn') || el.getAttribute('data-action') === 'scroll-to-top') { onTop(tabId); return; } if (el.classList.contains('header-down-btn') || el.getAttribute('data-action') === 'scroll-to-bottom') { onBottom(tabId); return; } const action = el.getAttribute('data-action') || ''; if (action === 'scroll-up-5') { onNav(-5, tabId); return; } if (action === 'scroll-down-5') { onNav(5, tabId); return; } }; document.addEventListener('click', handler, { passive: false, capture: true }); document.addEventListener('touchend', handler, { passive: false, capture: true }); // The phone remote drives these same three operations, so expose them rather than have the // remote synthesise clicks on header buttons that may not be rendered on every screen. window.__headerNav = { top: onTop, bottom: onBottom, by: onNav }; this._globalHeaderHandlersInstalled = true; } // NEW: Unified media tab initialization function - used for both startup and tab switching initializeMediaTab(tabId) { console.log('[DEBUG] Initializing media tab with comprehensive sequence:', tabId); // If the new Media Notes selector exists for this tab, skip legacy media canvas init const selectorExists = document.getElementById(`mediaSelectorContainer${tabId}`); if (selectorExists) { console.log('[DEBUG] Media Notes UI detected; skipping legacy media canvas initialization'); return; } // Step 1: Ensure media handler exists if (!this.localMediaHandler) { console.log('[DEBUG] Creating LocalMediaHandler during media tab init'); this.localMediaHandler = new LocalMediaHandler(); window.localMediaHandler = this.localMediaHandler; } // Step 2: Setup drawing canvas (same as tab switching) setTimeout(() => { if (window.localMediaHandler && typeof window.localMediaHandler.setupMediaDrawingCanvas === 'function') { window.localMediaHandler.setupMediaDrawingCanvas(tabId); console.log('[DEBUG] Step 1 complete: Setup media drawing canvas'); // Step 3: Restore strokes (same timing as tab switching) setTimeout(() => { window.localMediaHandler.restoreMediaStrokes(tabId); console.log('[DEBUG] Step 2 complete: Restored media strokes'); }, 100); // Step 4: Initialize interactions (same timing as tab switching) setTimeout(() => { if (window.localMediaHandler.refreshMediaInteractions) { window.localMediaHandler.refreshMediaInteractions(); console.log('[DEBUG] Step 3 complete: Refreshed media interactions'); } // Step 5: Ensure tools work properly setTimeout(() => { this.finalizeMediaTabSetup(tabId); console.log('[DEBUG] Step 4 complete: Finalized media tab setup'); }, 100); }, 200); } }, 50); } // NEW: Final setup steps to ensure everything works seamlessly finalizeMediaTabSetup(tabId) { // Ensure global functions are available if (window.ensureMediaInteractions) { window.ensureMediaInteractions(); } // Update tool UI to current state if (window.updateToolUI) { window.updateToolUI(); } // Ensure canvas pointer events are correct updateCanvasPointerEvents(); // IMMEDIATE FIX: Apply select tool cursor styling if select tool is active if (window.currentTool === 'select' && window.selectModeActive) { this.applySelectToolCursorImmediate(); setTimeout(() => { if (window.localMediaHandler && window.localMediaHandler.refreshMediaInteractions) { window.localMediaHandler.refreshMediaInteractions(); console.log('[DEBUG] Extra refresh for active select tool'); } }, 50); } // AGGRESSIVE FINAL FIX: Always check and apply select cursor at the very end setTimeout(() => { if (window.currentTool === 'select') { console.log('[FINALIZE] FINAL check - forcing select tool cursor regardless of conditions'); window.fixSelectToolCursor(); } }, 100); console.log('[DEBUG] Media tab fully initialized and ready for all tools'); // Start monitoring for continuous refresh behavior this.startMediaTabMonitoring(tabId); } // Continuously refresh active media tab just like a tab re-switch startMediaTabMonitoring(tabId) { if (this.mediaTabMonitor) { clearInterval(this.mediaTabMonitor); this.mediaTabMonitor = null; } console.log('[MONITOR] Starting media tab monitoring for tab:', tabId); this.mediaTabMonitor = setInterval(() => { if (document.visibilityState && document.visibilityState !== 'visible') return; try { const activeId = this.activeTabId; const activeTab = this.tabs.find(t => t.id === activeId); if (!activeTab || activeTab.mode !== 'media') { return; // Only when media tab is active } // Ensure drawing canvas exists and is styled correctly if (window.localMediaHandler && typeof window.localMediaHandler.ensureDrawingCanvasExists === 'function') { window.localMediaHandler.ensureDrawingCanvasExists(activeId); } // Keep z-index and pointer events correct for canvas and media items const canvases = document.querySelectorAll('.media-drawing-canvas'); canvases.forEach(c => { c.style.setProperty('display', 'block', 'important'); c.style.setProperty('pointer-events', window.currentTool === 'select' ? 'none' : 'auto', 'important'); c.style.setProperty('z-index', '5000', 'important'); if (window.localMediaHandler && window.localMediaHandler.updateCanvasStyling) { window.localMediaHandler.updateCanvasStyling(c); } }); // Refresh interactions and pointer-events on media items if (window.localMediaHandler && window.localMediaHandler.refreshMediaInteractions) { window.localMediaHandler.refreshMediaInteractions(); } // Apply CSS rule when drawing tools are active const isDrawingTool = window.currentTool === 'pen' || window.currentTool === 'highlight' || window.currentTool === 'erase'; updateMediaDrawingCSS(isDrawingTool); } catch (err) { console.warn('[MONITOR] Media tab monitor iteration error:', err); } }, 800); // Auto-stop after 5 minutes to avoid permanent background work setTimeout(() => { if (this.mediaTabMonitor) { clearInterval(this.mediaTabMonitor); this.mediaTabMonitor = null; console.log('[MONITOR] Media tab monitoring stopped (timeout)'); } }, 5 * 60 * 1000); } stopMediaTabMonitoring() { if (this.mediaTabMonitor) { clearInterval(this.mediaTabMonitor); this.mediaTabMonitor = null; console.log('[MONITOR] Media tab monitoring stopped'); } } // Alias for clarity in render flow populateDefaultNotes(tab) { return this.populateMediaNotes(tab); } // NOTES: Load default notes points from server and render cards async populateMediaNotes(tab) { try { const pointsCards = document.getElementById(`pointsCards${tab.id}`); const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const titleEl = document.getElementById(`mediaTitle${tab.id}`); const textEl = document.getElementById(`mediaText${tab.id}`); if (!pointsCards || !selector || !header || !titleEl || !textEl) return; titleEl.textContent = ''; // If this tab already has points loaded, just show the current point if (Array.isArray(tab.notesPoints) && tab.notesPoints.length > 0) { if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; const readingArea = document.getElementById(`readingArea${tab.id}`); if (readingArea) readingArea.style.display = 'block'; if (typeof tab.notesCurrentIndex !== 'number' || tab.notesCurrentIndex < 0 || tab.notesCurrentIndex >= tab.notesPoints.length) { tab.notesCurrentIndex = 0; } this.renderNotesPoint(tab, tab.notesCurrentIndex); this.setupNotesGestureNavigation(tab); return; } // Avoid flashing a parsing screen; keep selector hidden and fade into first slide try { const def = await fetch('/Notes/default.txt', { cache: 'no-store' }); if (def.ok) { const target = (await def.text()).trim(); if (/^slides\.pdf$/i.test(target)) { // Pre-hide selector and show reading area as a black stage const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) { readingArea.style.display = 'block'; readingArea.style.background = '#000'; } await this._loadPdfPagesAsPoints(tab, `/Notes/${encodeURIComponent(target)}`); return; } if (/^slides\.key$/i.test(target)) { // Route through server which aliases to sibling PDF await this.loadPointsViaServer(tab, target); return; } } } catch (_) {} await this.loadPointsViaServer(tab, ''); } catch (e) { console.error('Media Notes error:', e); } } async _loadPdfPagesAsPoints(tab, pdfUrl) { const pointsCards = document.getElementById(`pointsCards${tab.id}`); const titleEl = document.getElementById(`mediaTitle${tab.id}`); if (!pointsCards) return; if (titleEl) titleEl.textContent = decodeURIComponent(pdfUrl.split('/').pop() || 'slides.pdf'); // Cache-bust URL using server fileinfo so hot-swapped PDFs are picked up let versionedUrl = pdfUrl; try { const rawName = decodeURIComponent((pdfUrl.split('/').pop() || '').split('?')[0]); if (rawName) { const infoResp = await fetch(`/api/notes/fileinfo?file=${encodeURIComponent(rawName)}&t=${Date.now()}`, { cache: 'no-store' }); if (infoResp.ok) { const info = await infoResp.json(); const v = info && info.mtimeMs ? info.mtimeMs : Date.now(); versionedUrl = `/Notes/${encodeURIComponent(rawName)}?v=${v}`; const tabRef = tab || {}; tabRef._notesFileName = rawName; tabRef._notesFileSignature = { mtimeMs: info.mtimeMs || 0, size: info.size || 0 }; } } } catch (_) {} // Prepare a smooth stage without flashing cards try { const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) { readingArea.style.display = 'block'; readingArea.style.background = '#000'; } } catch (_) {} let pdfjsLib; try { pdfjsLib = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.8.69/build/pdf.min.mjs'); pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.8.69/build/pdf.worker.min.mjs'; } catch (e) { console.error('pdfjs import failed', e); try { pdfjsLib = await import('/node_modules/pdfjs-dist/build/pdf.min.mjs'); if (pdfjsLib && pdfjsLib.GlobalWorkerOptions) { pdfjsLib.GlobalWorkerOptions.workerSrc = '/node_modules/pdfjs-dist/build/pdf.worker.min.mjs'; } } catch (e2) { console.error('local pdfjs fallback failed', e2); pointsCards.innerHTML = `
PDF renderer unavailable
`; return; } } const loadingTask = pdfjsLib.getDocument({ url: versionedUrl, disableWorker: true }); const pdf = await loadingTask.promise; const numPages = pdf.numPages || 0; const points = Array.from({ length: numPages }, (_, i) => `Page ${i + 1}`); tab.notesMode = 'pdf-pages'; tab._notesPdfUrl = versionedUrl; tab._notesPdfDoc = pdf; tab._notesFirstRenderDone = false; // Start lightweight watcher to auto-reload when file changes on disk try { if (tab._notesWatcher) { clearInterval(tab._notesWatcher); } if (tab._notesFileName) { tab._notesWatcher = setInterval(async () => { if (document.visibilityState && document.visibilityState !== 'visible') return; try { const r = await fetch(`/api/notes/fileinfo?file=${encodeURIComponent(tab._notesFileName)}&t=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) return; const info = await r.json(); const prev = tab._notesFileSignature || {}; if (info.mtimeMs !== prev.mtimeMs || info.size !== prev.size) { tab._notesFileSignature = { mtimeMs: info.mtimeMs || 0, size: info.size || 0 }; const v = info && info.mtimeMs ? info.mtimeMs : Date.now(); const newUrl = `/Notes/${encodeURIComponent(tab._notesFileName)}?v=${v}`; const currentIndex = typeof tab.notesCurrentIndex === 'number' ? tab.notesCurrentIndex : 0; const reloadTask = pdfjsLib.getDocument({ url: newUrl, disableWorker: true }); const newPdf = await reloadTask.promise; tab._notesPdfDoc = newPdf; tab._notesPdfUrl = newUrl; const newCount = newPdf.numPages || 0; tab.notesPoints = Array.from({ length: newCount }, (_, i) => `Page ${i + 1}`); this.renderNotesPoint(tab, Math.max(0, Math.min(currentIndex, newCount - 1))); } } catch (_) {} }, 2500); } } catch (_) {} try { document.body.classList.add('slides-fullbleed'); // Add/update fixed top mask so nothing renders above the tabs let mask = document.getElementById('slidesTopMask'); if (!mask) { mask = document.createElement('div'); mask.id = 'slidesTopMask'; mask.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#000;z-index:1500;pointer-events:none;'; const appRoot = document.getElementById('app') || document.body; appRoot.appendChild(mask); } else { // Ensure mask is inside app root and ordered beneath toolbar const appRoot = document.getElementById('app') || document.body; if (mask.parentElement !== appRoot) appRoot.appendChild(mask); mask.style.zIndex = '1500'; } const toolbar = document.querySelector('.toolbar'); const toolbarBottom = toolbar ? Math.max(0, Math.round(toolbar.getBoundingClientRect().bottom)) : 60; mask.style.height = toolbarBottom + 'px'; } catch (_) {} // Ensure reading area height equals viewport minus toolbar try { const toolbar = document.querySelector('.toolbar'); const readingArea = document.getElementById(`readingArea${tab.id}`); if (toolbar && readingArea) { const h = Math.max(200, window.innerHeight - toolbar.offsetHeight); readingArea.style.height = `${h}px`; readingArea.style.maxHeight = `${h}px`; // Ensure reading area starts right below toolbar readingArea.style.marginTop = '0'; readingArea.style.paddingTop = '0'; } } catch (_) {} if (points.length === 0) { pointsCards.innerHTML = `
No pages detected in PDF
`; return; } // Auto-open first point, hide selector/header const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) readingArea.style.display = 'block'; tab.notesPoints = points; tab.notesCurrentIndex = 0; // Remove any existing global pages indicator (not used on touch TV) try { const indicatorId = `notesCount${tab.id}`; const el = document.getElementById(indicatorId); if (el && el.parentNode) el.parentNode.removeChild(el); } catch (_) {} this.renderNotesPoint(tab, 0); this.setupNotesGestureNavigation(tab); } // MEDIA NOTES: Parse PDF into points and render as cards, then show each point async loadPdfAndRenderPoints(tab, pdfUrl) { const pointsCards = document.getElementById(`pointsCards${tab.id}`); const textEl = document.getElementById(`mediaText${tab.id}`); if (!pointsCards || !textEl) return; pointsCards.innerHTML = `
Parsing PDF...
`; // Lazy import pdf.js for client-side text extraction let pdfjsLib; try { // Use CDN worker if local not found; we set window.__PDF_WORKER__ optionally pdfjsLib = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.8.69/build/pdf.min.mjs'); if (window.__PDF_WORKER__) { pdfjsLib.GlobalWorkerOptions.workerSrc = window.__PDF_WORKER__; } } catch (e) { console.warn('Failed to import pdfjs from CDN, trying fallback', e); try { pdfjsLib = await import('/node_modules/pdfjs-dist/build/pdf.min.mjs'); } catch (e2) { console.error('pdfjs import failed', e2); pointsCards.innerHTML = `
PDF parser unavailable
`; return; } } try { const loadingTask = pdfjsLib.getDocument(pdfUrl); const pdf = await loadingTask.promise; let fullText = ''; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const content = await page.getTextContent(); const strings = content.items.map(it => it.str); fullText += strings.join('\n') + '\n\n'; } // Heuristic split into points by lines that look like bullets or numbers const lines = fullText.split(/\r?\n/).map(l => l.trim()).filter(Boolean); const points = []; let current = []; const isPointStart = (s) => /^([\u2022\-\*\u25CF\u25A0]|\d+\.|[A-Z]\)|[A-Z]\.)\s+/.test(s); for (const line of lines) { if (isPointStart(line) && current.length) { points.push(current.join('\n')); current = [line]; } else { current.push(line); } } if (current.length) points.push(current.join('\n')); // Auto-open first point if (points.length === 0) { pointsCards.innerHTML = `
No points detected
`; return; } const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) readingArea.style.display = 'block'; tab.notesPoints = points; tab.notesCurrentIndex = 0; try { const indicatorId = `notesCount${tab.id}`; let el = document.getElementById(indicatorId); if (!el) { el = document.createElement('div'); el.id = indicatorId; el.style.cssText = 'position:absolute;top:8px;right:16px;color:#888;font-size:12px;'; document.body.appendChild(el); } el.textContent = `Points: ${points.length}`; } catch (_) {} const first = points[0] || ''; const html = first .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/^(\d+\.|[\u2022\-\*])\s+/gm, '$1 ') .replace(/\n/g, '
'); textEl.innerHTML = `
${html}
`; try { setupSlidesScripturePopups(tab); } catch (_) {} } catch (e) { console.error('PDF parse error', e); pointsCards.innerHTML = `
Failed to parse PDF
`; } } // MEDIA NOTES: Parse RTF into points (client-side minimal parser) async loadRtfAndRenderPoints(tab, rtfUrl) { const pointsCards = document.getElementById(`pointsCards${tab.id}`); const textEl = document.getElementById(`mediaText${tab.id}`); if (!pointsCards || !textEl) return; pointsCards.innerHTML = `
Parsing RTF...
`; try { const resp = await fetch(rtfUrl); if (!resp.ok) throw new Error('Failed to load RTF'); const rtfText = await resp.text(); // Minimal RTF to text: strip groups and control words; preserve line breaks heuristically let text = rtfText .replace(/\r\n/g, '\n') .replace(/\\par[d]?/g, '\n') .replace(/\{\\[^}]*\}/g, '') .replace(/\\'[0-9a-fA-F]{2}/g, (m) => { const hex = m.slice(2); try { return decodeURIComponent('%' + hex); } catch { return ' '; } }) .replace(/\\[^\s]+\s?/g, '') .replace(/[{}]/g, '') .replace(/\n{2,}/g, '\n'); const lines = text.split(/\n/).map(s => s.trim()).filter(Boolean); const points = []; let current = []; const isPointStart = (s) => /^([\u2022\-\*\u25CF\u25A0]|\d+\.|[A-Z]\)|[A-Z]\.)\s+/.test(s); for (const line of lines) { if (isPointStart(line) && current.length) { points.push(current.join('\n')); current = [line]; } else { current.push(line); } } if (current.length) points.push(current.join('\n')); if (points.length === 0) { pointsCards.innerHTML = `
No points detected
`; return; } pointsCards.innerHTML = points.map((p, idx) => `
Point ${idx + 1}
`).join(''); // Store points on the container so delegated handler always uses latest data pointsCards._rtfPoints = points; if (!pointsCards._rtfDelegated) { pointsCards.addEventListener('click', (ev) => { const card = ev.target.closest && ev.target.closest('.selection-card'); if (!card || !pointsCards.contains(card)) return; const idx = parseInt(card.getAttribute('data-point-index'), 10); const pts = pointsCards._rtfPoints || []; const content = pts[idx] || ''; const html = content .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/^(\d+\.|[\u2022\-\*])\s+/gm, '$1 ') .replace(/\n/g, '
'); textEl.innerHTML = `
${html}
`; try { setupSlidesScripturePopups(tab); } catch (_) {} requestAnimationFrame(() => { try { setupAnnotationForActiveTab(); } catch (_) {} }); }); pointsCards._rtfDelegated = true; } } catch (e) { console.error('RTF parse error', e); pointsCards.innerHTML = `
Failed to parse RTF
`; } } // MEDIA NOTES: Unified server-side points loading (PDF/RTF/TXT) async loadPointsViaServer(tab, fileName) { const pointsCards = document.getElementById(`pointsCards${tab.id}`); const textEl = document.getElementById(`mediaText${tab.id}`); const titleEl = document.getElementById(`mediaTitle${tab.id}`); if (!pointsCards || !textEl) return; pointsCards.innerHTML = `
Parsing...
`; try { const debugParam = (localStorage.getItem('notes.debug') === '1') ? '&debug=1' : ''; const resp = await fetch(`/api/notes/points?file=${encodeURIComponent(fileName)}${debugParam}`); if (!resp.ok) throw new Error('Failed to parse on server'); const data = await resp.json(); if (data && data.mode === 'pdf-pages' && data.url) { // Special page-per-point mode for slides.pdf if (titleEl && data.file) titleEl.textContent = data.file; // Smooth stage; no parsing card try { const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) { readingArea.style.display = 'block'; readingArea.style.background = '#000'; } } catch (_) {} // Lazy import pdf.js for rendering pages as images let pdfjsLib; try { pdfjsLib = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.8.69/build/pdf.min.mjs'); pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.8.69/build/pdf.worker.min.mjs'; } catch (e) { console.error('pdfjs import failed', e); try { pdfjsLib = await import('/node_modules/pdfjs-dist/build/pdf.min.mjs'); if (pdfjsLib && pdfjsLib.GlobalWorkerOptions) { pdfjsLib.GlobalWorkerOptions.workerSrc = '/node_modules/pdfjs-dist/build/pdf.worker.min.mjs'; } } catch (e2) { console.error('local pdfjs fallback failed', e2); pointsCards.innerHTML = `
PDF renderer unavailable
`; return; } } // Cache-bust the served PDF URL using fileinfo so hot-swaps are detected let servedUrl = data.url; try { const filePart = decodeURIComponent((servedUrl.split('/').pop() || '').split('?')[0]); if (filePart) { const infoResp = await fetch(`/api/notes/fileinfo?file=${encodeURIComponent(filePart)}&t=${Date.now()}`, { cache: 'no-store' }); if (infoResp.ok) { const info = await infoResp.json(); const v = info && info.mtimeMs ? info.mtimeMs : Date.now(); servedUrl = `/Notes/${encodeURIComponent(filePart)}?v=${v}`; tab._notesFileName = filePart; tab._notesFileSignature = { mtimeMs: info.mtimeMs || 0, size: info.size || 0 }; } } } catch (_) {} const loadingTask = pdfjsLib.getDocument({ url: servedUrl, disableWorker: true }); const pdf = await loadingTask.promise; const numPages = pdf.numPages || 0; const points = Array.from({ length: numPages }, (_, i) => `Page ${i + 1}`); // Store mode and pdf doc on tab tab.notesMode = 'pdf-pages'; tab._notesPdfUrl = servedUrl; tab._notesPdfDoc = pdf; tab._notesFirstRenderDone = false; // Start lightweight watcher to auto-reload when file changes on disk try { if (tab._notesWatcher) { clearInterval(tab._notesWatcher); } if (tab._notesFileName) { tab._notesWatcher = setInterval(async () => { if (document.visibilityState && document.visibilityState !== 'visible') return; try { const r = await fetch(`/api/notes/fileinfo?file=${encodeURIComponent(tab._notesFileName)}&t=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) return; const info = await r.json(); const prev = tab._notesFileSignature || {}; if (info.mtimeMs !== prev.mtimeMs || info.size !== prev.size) { tab._notesFileSignature = { mtimeMs: info.mtimeMs || 0, size: info.size || 0 }; const v = info && info.mtimeMs ? info.mtimeMs : Date.now(); const newUrl = `/Notes/${encodeURIComponent(tab._notesFileName)}?v=${v}`; const currentIndex = typeof tab.notesCurrentIndex === 'number' ? tab.notesCurrentIndex : 0; const reloadTask = pdfjsLib.getDocument({ url: newUrl, disableWorker: true }); const newPdf = await reloadTask.promise; tab._notesPdfDoc = newPdf; tab._notesPdfUrl = newUrl; const newCount = newPdf.numPages || 0; tab.notesPoints = Array.from({ length: newCount }, (_, i) => `Page ${i + 1}`); this.renderNotesPoint(tab, Math.max(0, Math.min(currentIndex, newCount - 1))); } } catch (_) {} }, 2500); } } catch (_) {} // Ensure no global pages indicator is shown try { const indicatorId = `notesCount${tab.id}`; const el = document.getElementById(indicatorId); if (el && el.parentNode) el.parentNode.removeChild(el); } catch (_) {} if (points.length === 0) { pointsCards.innerHTML = `
No pages detected in PDF
`; return; } // Auto-open first page const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) readingArea.style.display = 'block'; tab.notesPoints = points; // placeholder array for length/navigation tab.notesCurrentIndex = typeof tab.notesCurrentIndex === 'number' && tab.notesCurrentIndex >= 0 && tab.notesCurrentIndex < points.length ? tab.notesCurrentIndex : 0; try { const indicatorId = `notesCount${tab.id}`; const el = document.getElementById(indicatorId); if (el && el.parentNode) el.parentNode.removeChild(el); } catch (_) {} this.renderNotesPoint(tab, tab.notesCurrentIndex); this.setupNotesGestureNavigation(tab); return; } if (data && data.mode === 'image' && data.url) { if (titleEl && data.file) titleEl.textContent = data.file; try { const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) { readingArea.style.display = 'block'; readingArea.style.background = '#000'; readingArea.style.overflow = 'auto'; readingArea.style.padding = '0'; } const container = document.getElementById(`mediaText${tab.id}`); if (container) { container.innerHTML = ''; const bustUrl = data.url.includes('?') ? `${data.url}&t=${Date.now()}` : `${data.url}?t=${Date.now()}`; const img = document.createElement('img'); img.src = bustUrl; img.alt = data.file || 'Notes graphic'; img.style.display = 'block'; img.style.margin = '0'; img.style.width = '100%'; // edge to edge, like the Notes tab and the announcements img.style.maxWidth = '100%'; img.style.height = 'auto'; container.style.display = 'block'; container.style.padding = '0'; container.style.width = '100%'; container.style.minHeight = 'auto'; container.appendChild(img); tab._notesFileName = data.file; tab._notesFileSignature = { mtimeMs: data.mtimeMs || 0, size: data.size || 0 }; } tab.notesPoints = []; tab.notesCurrentIndex = 0; try { setupAnnotationForActiveTab(); } catch (_) {} } catch (err) { console.error('Failed to render image notes', err); } return; } const points = Array.isArray(data.points) ? data.points : []; if (titleEl && data.file) titleEl.textContent = data.file; // Optional: quick visual count indicator for verification (dev-only) try { if (data.count != null) { const indicatorId = `notesCount${tab.id}`; let el = document.getElementById(indicatorId); if (!el) { el = document.createElement('div'); el.id = indicatorId; el.style.cssText = 'position:absolute;top:8px;right:16px;color:#888;font-size:12px;'; document.body.appendChild(el); } el.textContent = `Points: ${data.count}`; } } catch (_) {} if (points.length === 0) { pointsCards.innerHTML = `
No points detected. Add a .rtf or .pdf to Notes/ or set Notes/default.txt
`; return; } const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) readingArea.style.display = 'block'; tab.notesPoints = points; tab.notesCurrentIndex = 0; try { const indicatorId = `notesCount${tab.id}`; let el = document.getElementById(indicatorId); if (!el) { el = document.createElement('div'); el.id = indicatorId; el.style.cssText = 'position:absolute;top:8px;right:16px;color:#888;font-size:12px;'; document.body.appendChild(el); } el.textContent = `Points: ${points.length}`; } catch (_) {} this.renderNotesPoint(tab, 0); this.setupNotesGestureNavigation(tab); } catch (e) { console.error('Server points parse error', e); // Fallback: if default points to slides.pdf, try client page-per-point directly (without flashing UI) try { const defaultResp = await fetch('/Notes/default.txt', { cache: 'no-store' }); if (defaultResp.ok) { const target = (await defaultResp.text()).trim(); if (/^slides\.(pdf|key)$/i.test(target)) { const effective = target.replace(/\.key$/i, '.pdf'); // Version the URL so hot-swapped PDFs are reloaded let url = `/Notes/${encodeURIComponent(effective)}`; try { const infoResp = await fetch(`/api/notes/fileinfo?file=${encodeURIComponent(effective)}&t=${Date.now()}`, { cache: 'no-store' }); if (infoResp.ok) { const info = await infoResp.json(); const v = info && info.mtimeMs ? info.mtimeMs : Date.now(); url = `/Notes/${encodeURIComponent(effective)}?v=${v}`; tab._notesFileName = effective; tab._notesFileSignature = { mtimeMs: info.mtimeMs || 0, size: info.size || 0 }; } } catch (_) {} // Reuse the same page-per-point flow if (titleEl) titleEl.textContent = target; try { const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) { readingArea.style.display = 'block'; readingArea.style.background = '#000'; } } catch (_) {} // Lazy import pdf.js let pdfjsLib; try { pdfjsLib = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.8.69/build/pdf.min.mjs'); pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.8.69/build/pdf.worker.min.mjs'; } catch (e1) { try { pdfjsLib = await import('/node_modules/pdfjs-dist/build/pdf.min.mjs'); if (pdfjsLib && pdfjsLib.GlobalWorkerOptions) { pdfjsLib.GlobalWorkerOptions.workerSrc = '/node_modules/pdfjs-dist/build/pdf.worker.min.mjs'; } } catch (e2) { pointsCards.innerHTML = `
Failed to parse notes
`; return; } } const loadingTask = pdfjsLib.getDocument({ url, disableWorker: true }); const pdf = await loadingTask.promise; const numPages = pdf.numPages || 0; const points = Array.from({ length: numPages }, (_, i) => `Page ${i + 1}`); tab.notesMode = 'pdf-pages'; tab._notesPdfUrl = url; tab._notesPdfDoc = pdf; if (points.length === 0) { pointsCards.innerHTML = `
No pages detected in PDF
`; return; } // Auto-open first page and show count const selector = document.getElementById(`mediaSelectorContainer${tab.id}`); const header = document.getElementById(`mediaHeader${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'none'; if (header) header.style.display = 'none'; if (readingArea) readingArea.style.display = 'block'; tab.notesPoints = points; tab.notesCurrentIndex = 0; try { const indicatorId = `notesCount${tab.id}`; const el = document.getElementById(indicatorId); if (el && el.parentNode) el.parentNode.removeChild(el); } catch (_) {} this.renderNotesPoint(tab, 0); this.setupNotesGestureNavigation(tab); return; } } } catch (_) {} pointsCards.innerHTML = `
Failed to parse notes
`; } } renderNotesPoint(tab, index) { const textEl = document.getElementById(`mediaText${tab.id}`); if (!tab || !Array.isArray(tab.notesPoints) || !textEl) return; const clamped = Math.max(0, Math.min(index, tab.notesPoints.length - 1)); tab.notesCurrentIndex = clamped; // Close any open popups when changing slides try { if (typeof closeWordPopup === 'function') closeWordPopup(); } catch (_) {} try { const ra = document.getElementById(`readingArea${tab.id}`); if (ra) ra.querySelectorAll('.word-info-popup').forEach(p => p.remove()); } catch (_) {} if (tab.notesMode === 'pdf-pages' && tab._notesPdfDoc) { try { document.body.classList.add('slides-fullbleed'); let mask = document.getElementById('slidesTopMask'); if (!mask) { mask = document.createElement('div'); mask.id = 'slidesTopMask'; mask.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#000;z-index:1500;pointer-events:none;'; const appRoot = document.getElementById('app') || document.body; appRoot.appendChild(mask); } else { const appRoot = document.getElementById('app') || document.body; if (mask.parentElement !== appRoot) appRoot.appendChild(mask); mask.style.zIndex = '1500'; } const toolbar = document.querySelector('.toolbar'); const toolbarBottom = toolbar ? Math.max(0, Math.round(toolbar.getBoundingClientRect().bottom)) : 60; mask.style.height = toolbarBottom + 'px'; } catch (_) {} // Render selected PDF page to an image for perfect fidelity const pageIndex = clamped + 1; // Token to cancel stale renders: if a newer slide render starts, this older one must // not commit its page to the DOM after its awaits resolve (prevents wrong-slide flashes). const myRenderSeq = (tab._notesRenderSeq = (tab._notesRenderSeq || 0) + 1); (async () => { try { const page = await tab._notesPdfDoc.getPage(pageIndex); if (tab._notesRenderSeq !== myRenderSeq) return; // superseded by a newer slide const readingArea = document.getElementById(`readingArea${tab.id}`); const toolbar = document.querySelector('.toolbar'); const viewportHeight = window.innerHeight - (toolbar ? toolbar.offsetHeight : 0); const containerWidth = Math.max(400, (readingArea ? readingArea.clientWidth : (textEl.clientWidth || window.innerWidth))); const containerHeight = Math.max(200, (readingArea ? readingArea.clientHeight : viewportHeight)); const viewport = page.getViewport({ scale: 1 }); const deviceRatio = Math.min(window.devicePixelRatio || 1, 2); // Scale to COVER the available area so left/right and bottom touch; crop from top if necessary const widthScale = containerWidth / viewport.width; const heightScale = containerHeight / viewport.height; const coverScale = Math.max(widthScale, heightScale) * deviceRatio; const scaled = page.getViewport({ scale: coverScale }); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { alpha: false }); canvas.width = Math.floor(scaled.width); canvas.height = Math.floor(scaled.height); await page.render({ canvasContext: ctx, viewport: scaled }).promise; if (tab._notesRenderSeq !== myRenderSeq) return; // superseded by a newer slide textEl.innerHTML = `
`; const holder = textEl.querySelector('.passage-text'); holder.appendChild(canvas); holder.style.position = 'relative'; holder.style.overflow = 'hidden'; holder.style.width = '100%'; holder.style.height = `${containerHeight}px`; holder.style.display = 'flex'; holder.style.justifyContent = 'center'; holder.style.alignItems = 'flex-end'; holder.style.margin = '0 auto'; const cssWidth = Math.floor(canvas.width / deviceRatio); const cssHeight = Math.floor(canvas.height / deviceRatio); canvas.style.display = 'block'; canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; // Center horizontally; anchor bottom vertically (crop top if needed) const leftOffset = Math.floor((containerWidth - cssWidth) / 2); const topOffset = Math.floor(containerHeight - cssHeight); canvas.style.position = 'relative'; canvas.style.left = '0'; canvas.style.top = '0'; canvas.style.margin = '0 auto'; canvas.style.maxWidth = 'none'; canvas.classList.add('media-fade'); requestAnimationFrame(() => canvas.classList.add('media-fade-visible')); if (!tab._notesFirstRenderDone) { canvas.classList.add('slides-first-fade-in'); tab._notesFirstRenderDone = true; } // Detect scripture references on PDF pages and add precise overlays try { const textContent = await page.getTextContent(); const items = textContent.items || []; // Create precise overlays matching the text bounds addPreciseScriptureOverlaysFromItems( items, coverScale, // scale used for rendering deviceRatio, leftOffset, // canvas CSS left within holder topOffset, // canvas CSS top within holder cssWidth, cssHeight, holder, tab ); // Removed center badge test buttons; precise overlays are sufficient for taps } catch (e) { console.warn('PDF textContent error', e); } try { setupAnnotationForActiveTab(); } catch (_) {} } catch (e) { console.error('Failed to render PDF page', e); textEl.innerHTML = `
Failed to render PDF page ${pageIndex}
`; } })(); // Ensure navigation UI exists even for PDF mode } else { try { document.body.classList.remove('slides-fullbleed'); const mask = document.getElementById('slidesTopMask'); if (mask) mask.remove(); } catch (_) {} const content = tab.notesPoints[clamped] || ''; const html = content .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/^(\d+\.|[\u2022\-\*])\s+/gm, '$1 ') .replace(/\n/g, '
'); textEl.innerHTML = `
${html}
`; try { setupSlidesScripturePopups(tab); } catch (_) {} // Annotations are disabled in media (Notes/Slides) mode try { const drawingCanvases = document.querySelectorAll('.media-drawing-canvas'); drawingCanvases.forEach(c => c.remove()); } catch (_) {} // Annotations are disabled in media (Notes/Slides) mode try { const drawingCanvases = document.querySelectorAll('.media-drawing-canvas'); drawingCanvases.forEach(c => c.remove()); } catch (_) {} } // Ensure on-screen navigation arrows exist and are wired (for laptop testing) try { const readingArea = document.getElementById(`readingArea${tab.id}`); if (readingArea) { let nav = readingArea.querySelector('.notes-nav'); if (!nav) { nav = document.createElement('div'); nav.className = 'notes-nav'; nav.innerHTML = ` `; nav.style.position = 'absolute'; nav.style.top = '12px'; nav.style.right = '12px'; nav.style.zIndex = '6000'; nav.querySelectorAll('button').forEach(btn => { btn.style.marginLeft = '8px'; btn.style.padding = '8px 12px'; btn.style.background = '#2a2a2a'; btn.style.border = '2px solid #404040'; btn.style.color = '#e0e0e0'; btn.style.borderRadius = '8px'; btn.style.cursor = 'pointer'; }); readingArea.style.position = 'relative'; // Hide on-screen arrows for slides mode if (tab.notesMode === 'pdf-pages') { nav.style.display = 'none'; } readingArea.appendChild(nav); const prevBtn = nav.querySelector('.notes-prev'); const nextBtn = nav.querySelector('.notes-next'); const goPrev = (e) => { e.stopPropagation(); e.preventDefault(); this.renderNotesPoint(tab, (tab.notesCurrentIndex||0) - 1); }; const goNext = (e) => { e.stopPropagation(); e.preventDefault(); this.renderNotesPoint(tab, (tab.notesCurrentIndex||0) + 1); }; prevBtn.addEventListener('click', goPrev, { passive: false }); nextBtn.addEventListener('click', goNext, { passive: false }); // Touch-friendly bindings prevBtn.addEventListener('touchstart', goPrev, { passive: false }); nextBtn.addEventListener('touchstart', goNext, { passive: false }); } } } catch (_) {} } setupNotesGestureNavigation(tab) { const panel = document.querySelector(`.tab-panel[data-tab-id="${tab.id}"]`) || document; if (!panel) return; // Remove previous if (tab._notesGestureHandler) { panel.removeEventListener('pointerdown', tab._notesGestureHandler, { passive: true }); panel.removeEventListener('pointermove', tab._notesGestureHandler, { passive: true }); panel.removeEventListener('pointerup', tab._notesGestureHandler, { passive: true }); panel.removeEventListener('pointercancel', tab._notesGestureHandler, { passive: true }); } // Disable scrolling in Slides/Notes reading area and panel try { const readingArea = document.getElementById(`readingArea${tab.id}`); if (readingArea) { // Allow text selection in prayer tabs - don't block touch-action const isPrayerTab = readingArea.querySelector && readingArea.querySelector('.prayer-text'); if (!isPrayerTab) { readingArea.style.setProperty('overflow', 'hidden', 'important'); readingArea.style.setProperty('touch-action', 'none', 'important'); readingArea.style.setProperty('overscroll-behavior', 'none', 'important'); } else { readingArea.style.setProperty('touch-action', 'manipulation', 'important'); } // Allowlist for interactive elements inside Slides const isSlidesInteractiveTarget = (el) => { if (!el || !el.closest) return false; return !!( el.closest('.notes-nav') || el.closest('.slide-scripture-ref') || el.closest('.slide-scripture-badge') || // Allow popup drag/close interactions exactly like Bible tabs el.closest('.word-info-popup') || el.closest('.word-info-header') || el.closest('.word-info-close') ); }; try { const contentWrapper = document.getElementById(`contentWrapper${tab.id}`); if (contentWrapper) { // Allow text selection in prayer tabs const isPrayerTab = contentWrapper.querySelector && contentWrapper.querySelector('.prayer-text'); if (!isPrayerTab) { contentWrapper.style.setProperty('overflow', 'hidden', 'important'); contentWrapper.style.setProperty('touch-action', 'none', 'important'); contentWrapper.style.setProperty('overscroll-behavior', 'none', 'important'); } else { contentWrapper.style.setProperty('touch-action', 'manipulation', 'important'); } } } catch (_) {} // Capture wheel/touch on reading area itself to ensure no scroll leaks const block = (ev) => { try { if (window._popupDragging) return; // allow dragging popups to receive all events if (isSlidesInteractiveTarget(ev.target)) return; // let interactive taps through // Allow text selection in prayer tabs if (ev.target.closest && ev.target.closest('.prayer-text')) return; if (ev.target.classList && ev.target.classList.contains('prayer-text')) return; ev.preventDefault(); ev.stopPropagation(); } catch (_) {} }; if (!readingArea._blocksApplied) { readingArea.addEventListener('wheel', block, { passive: false, capture: true }); readingArea.addEventListener('touchstart', block, { passive: false, capture: true }); readingArea.addEventListener('touchmove', block, { passive: false, capture: true }); readingArea.addEventListener('touchend', block, { passive: false, capture: true }); readingArea.addEventListener('gesturestart', block, { passive: false, capture: true }); readingArea.addEventListener('gesturechange', block, { passive: false, capture: true }); readingArea._blocksApplied = true; } } if (panel && panel.style) { // Allow text selection in prayer tabs - don't block touch-action const isPrayerTab = panel.querySelector && panel.querySelector('.prayer-text'); if (!isPrayerTab) { panel.style.setProperty('touch-action', 'none', 'important'); panel.style.setProperty('overflow', 'hidden', 'important'); panel.style.setProperty('overscroll-behavior', 'none', 'important'); } else { panel.style.setProperty('touch-action', 'manipulation', 'important'); } const blockPanel = (ev) => { try { if (window._popupDragging) return; // allow dragging popups if (isSlidesInteractiveTarget(ev.target)) return; // allow interactive elements // Allow text selection in prayer tabs if (ev.target.closest && ev.target.closest('.prayer-text')) return; if (ev.target.classList && ev.target.classList.contains('prayer-text')) return; ev.preventDefault(); ev.stopPropagation(); } catch (_) {} }; if (!panel._blocksApplied) { panel.addEventListener('wheel', blockPanel, { passive: false, capture: true }); panel.addEventListener('touchstart', blockPanel, { passive: false, capture: true }); panel.addEventListener('touchmove', blockPanel, { passive: false, capture: true }); panel.addEventListener('touchend', blockPanel, { passive: false, capture: true }); panel.addEventListener('gesturestart', blockPanel, { passive: false, capture: true }); panel.addEventListener('gesturechange', blockPanel, { passive: false, capture: true }); panel._blocksApplied = true; } } } catch (_) {} let activeIds = new Set(); let gestureStart = 0; let peakTouchCount = 0; const pointerStart = new Map(); // id -> {x,y} const pointerEnd = new Map(); // id -> {x,y} const evaluateSwipe = () => { // Single-finger horizontal swipe to navigate if (peakTouchCount < 1) return; // Average delta across all pointers that participated let sumDx = 0, sumDy = 0, n = 0; pointerEnd.forEach((end, id) => { const st = pointerStart.get(id); if (!st) return; sumDx += (end.x - st.x); sumDy += (end.y - st.y); n++; }); if (n === 0) return; const avgDx = sumDx / n; const avgDy = sumDy / n; const absDx = Math.abs(avgDx); const absDy = Math.abs(avgDy); const duration = performance.now() - gestureStart; const horizontalEnough = absDx > 60 && absDx > absDy * 1.5; if (duration <= 1200 && horizontalEnough) { if (avgDx < 0) { this.renderNotesPoint(tab, (tab.notesCurrentIndex || 0) + 1); } else { this.renderNotesPoint(tab, (tab.notesCurrentIndex || 0) - 1); } } }; const handler = (e) => { if (isSlidesInteractiveTarget(e.target)) return; if (e.type === 'pointerdown') { if (activeIds.size === 0) { gestureStart = performance.now(); peakTouchCount = 0; pointerStart.clear(); pointerEnd.clear(); } activeIds.add(e.pointerId); peakTouchCount = Math.max(peakTouchCount, activeIds.size); pointerStart.set(e.pointerId, { x: e.clientX, y: e.clientY }); pointerEnd.set(e.pointerId, { x: e.clientX, y: e.clientY }); try { e.preventDefault(); } catch (_) {} } else if (e.type === 'pointermove') { if (!activeIds.has(e.pointerId)) return; pointerEnd.set(e.pointerId, { x: e.clientX, y: e.clientY }); try { e.preventDefault(); } catch (_) {} } else if (e.type === 'pointerup' || e.type === 'pointercancel') { pointerEnd.set(e.pointerId, { x: e.clientX, y: e.clientY }); activeIds.delete(e.pointerId); if (activeIds.size === 0) { evaluateSwipe(); } try { e.preventDefault(); } catch (_) {} } }; tab._notesGestureHandler = handler; panel.addEventListener('pointerdown', handler, { passive: true }); panel.addEventListener('pointermove', handler, { passive: true }); panel.addEventListener('pointerup', handler, { passive: true }); panel.addEventListener('pointercancel', handler, { passive: true }); // Touch fallback for devices; treat single-finger as swipe nav and block scroll if (tab._notesTouchStart) { panel.removeEventListener('touchstart', tab._notesTouchStart, { capture: true }); panel.removeEventListener('touchmove', tab._notesTouchMove, { capture: true }); panel.removeEventListener('touchend', tab._notesTouchEnd, { capture: true }); panel.removeEventListener('touchcancel', tab._notesTouchEnd, { capture: true }); } let tStart = 0; let tPeak = 0; const touchStarts = new Map(); // id -> {x,y} const touchEnds = new Map(); // id -> {x,y} const isNavTarget = (el) => !!(el && el.closest && el.closest('.notes-nav')); const isSlidesInteractiveTarget = (el) => { if (!el || !el.closest) return false; return !!( el.closest('.notes-nav') || el.closest('.slide-scripture-ref') || el.closest('.slide-scripture-badge') || // Allow popup interactions (drag, close) inside Slides el.closest('.word-info-popup') || el.closest('.word-info-close') || el.closest('.word-info-header') ); }; const onTouchStart = (e) => { // Capture to ensure we see all touches; prevent default to avoid gesture handling if (isSlidesInteractiveTarget(e.target)) return; // allow buttons to work normally e.preventDefault(); if (e.touches.length === 1) { tStart = performance.now(); tPeak = 0; touchStarts.clear(); touchEnds.clear(); } tPeak = Math.max(tPeak, e.touches.length); for (let i = 0; i < e.changedTouches.length; i++) { const t = e.changedTouches[i]; touchStarts.set(t.identifier, { x: t.clientX, y: t.clientY }); touchEnds.set(t.identifier, { x: t.clientX, y: t.clientY }); } }; const onTouchMove = (e) => { if (isSlidesInteractiveTarget(e.target)) return; // Always block default to prevent scrolling/zooming e.preventDefault(); for (let i = 0; i < e.changedTouches.length; i++) { const t = e.changedTouches[i]; touchEnds.set(t.identifier, { x: t.clientX, y: t.clientY }); } }; const onTouchEnd = (e) => { if (isSlidesInteractiveTarget(e.target)) return; e.preventDefault(); if (e.touches.length === 0) { const dur = performance.now() - tStart; if (tPeak >= 1 && dur <= 1500) { // Compute average delta across all touches let sumDx = 0, sumDy = 0, n = 0; touchEnds.forEach((end, id) => { const st = touchStarts.get(id); if (!st) return; sumDx += (end.x - st.x); sumDy += (end.y - st.y); n++; }); if (n > 0) { const avgDx = sumDx / n; const avgDy = sumDy / n; const absDx = Math.abs(avgDx), absDy = Math.abs(avgDy); if (absDx > 60 && absDx > absDy * 1.5) { if (avgDx < 0) this.renderNotesPoint(tab, (tab.notesCurrentIndex || 0) + 1); else this.renderNotesPoint(tab, (tab.notesCurrentIndex || 0) - 1); } } } } }; tab._notesTouchStart = onTouchStart; tab._notesTouchMove = onTouchMove; tab._notesTouchEnd = onTouchEnd; panel.addEventListener('touchstart', onTouchStart, { passive: false, capture: true }); panel.addEventListener('touchmove', onTouchMove, { passive: false, capture: true }); panel.addEventListener('touchend', onTouchEnd, { passive: false, capture: true }); panel.addEventListener('touchcancel', onTouchEnd, { passive: false, capture: true }); // Block wheel-based scrolling entirely inside the panel if (tab._notesWheelBlocker) { panel.removeEventListener('wheel', tab._notesWheelBlocker, { capture: true }); } const wheelBlocker = (e) => { e.preventDefault(); }; tab._notesWheelBlocker = wheelBlocker; panel.addEventListener('wheel', wheelBlocker, { passive: false, capture: true }); // Also block gesture events commonly used for two-finger scroll/zoom try { if (!tab._gestureStartBlocker) { tab._gestureStartBlocker = (e) => { try { e.preventDefault(); } catch (_) {} }; panel.addEventListener('gesturestart', tab._gestureStartBlocker, { passive: false, capture: true }); } if (!tab._gestureChangeBlocker) { tab._gestureChangeBlocker = (e) => { try { e.preventDefault(); } catch (_) {} }; panel.addEventListener('gesturechange', tab._gestureChangeBlocker, { passive: false, capture: true }); } } catch (_) {} // Block keyboard scroll keys in Slides/Notes if (tab._notesKeyBlocker) { window.removeEventListener('keydown', tab._notesKeyBlocker, true); } // CRITICAL FIX: Defensive handling for keyboard disconnect events - prevent black screen bug const keyBlocker = (e) => { try { // Defensive check: ensure event object is valid before accessing properties if (!e || typeof e !== 'object' || typeof e.key !== 'string') return; const keys = ['PageUp','PageDown','Home','End','ArrowUp','ArrowDown','Space',' ']; if (keys.includes(e.key)) { try { e.preventDefault(); e.stopPropagation(); } catch (_) {} } } catch (err) { // Silently handle any errors from malformed keyboard events (e.g., keyboard disconnect) console.warn('[KEYBOARD] Key blocker error (likely keyboard disconnect):', err); } }; tab._notesKeyBlocker = keyBlocker; window.addEventListener('keydown', keyBlocker, true); // Keyboard arrows for laptop testing if (tab._notesKeyHandler) { window.removeEventListener('keydown', tab._notesKeyHandler, true); } // CRITICAL FIX: Defensive handling for keyboard disconnect events - prevent black screen bug const keyHandler = (e) => { try { // Defensive check: ensure event object is valid before accessing properties if (!e || typeof e !== 'object' || typeof e.key !== 'string') return; const activeId = window.bibleApp?.activeTabId; const activeTab = window.bibleApp?.tabs?.find(t => t.id === activeId); if (!activeTab || activeTab.id !== tab.id || activeTab.mode !== 'media') return; if (e.key === 'ArrowLeft') { this.renderNotesPoint(tab, (tab.notesCurrentIndex||0) - 1); } else if (e.key === 'ArrowRight') { this.renderNotesPoint(tab, (tab.notesCurrentIndex||0) + 1); } } catch (err) { // Silently handle any errors from malformed keyboard events (e.g., keyboard disconnect) console.warn('[KEYBOARD] Key handler error (likely keyboard disconnect):', err); } }; tab._notesKeyHandler = keyHandler; window.addEventListener('keydown', keyHandler, true); } // ATF support removed per request // NEW: Monitoring system to ensure select tool cursor always works startSelectToolMonitoring() { console.log('[MONITOR] Starting select tool cursor monitoring system'); // Check every 2 seconds this.selectToolMonitor = setInterval(() => { if (document.visibilityState && document.visibilityState !== 'visible') return; // Only monitor if select tool is active if (window.currentTool === 'select' && window.selectModeActive) { const mediaItems = document.querySelectorAll('.canvas-media-item'); if (mediaItems.length > 0) { // Check if any media items have the wrong cursor const hasWrongCursor = Array.from(mediaItems).some(item => { const cursor = getComputedStyle(item).cursor; return cursor !== 'move' && !item.style.cursor.includes('move'); }); if (hasWrongCursor) { console.log('[MONITOR] Select tool cursor is broken - auto-fixing now'); // ?? LAYER FIX: Use gentle cursor fix that doesn't override layering const mediaItems = document.querySelectorAll('.canvas-media-item'); mediaItems.forEach(item => { item.style.setProperty('cursor', 'move', 'important'); item.style.setProperty('pointer-events', 'auto', 'important'); // Don't override z-index - let layer manager handle it }); } } } }, 2000); // Check every 2 seconds // Stop monitoring after 30 seconds to avoid permanent overhead setTimeout(() => { if (this.selectToolMonitor) { clearInterval(this.selectToolMonitor); console.log('[MONITOR] Select tool monitoring stopped after 30 seconds'); } }, 30000); } // NEW: Immediate select tool cursor application for startup applySelectToolCursorImmediate() { console.log('[CURSOR FIX] Applying select tool cursor immediately'); // Apply the CSS override immediately let selectToolStyle = document.getElementById('select-tool-cursor-override'); if (!selectToolStyle) { selectToolStyle = document.createElement('style'); selectToolStyle.id = 'select-tool-cursor-override'; document.head.appendChild(selectToolStyle); } selectToolStyle.textContent = ` .canvas-media-item, .canvas-media-item * { cursor: move !important; pointer-events: auto !important; } .media-drawing-canvas { pointer-events: none !important; } `; // Apply cursor to any existing media items immediately const mediaItems = document.querySelectorAll('.canvas-media-item'); mediaItems.forEach(item => { item.style.setProperty('cursor', 'move', 'important'); item.style.setProperty('pointer-events', 'auto', 'important'); // Also apply to child elements const childElements = item.querySelectorAll('*'); childElements.forEach(child => { child.style.setProperty('cursor', 'move', 'important'); child.style.setProperty('pointer-events', 'auto', 'important'); }); }); console.log('[CURSOR FIX] Select tool cursor applied to', mediaItems.length, 'media items'); } // NEW: Robust system health check and auto-repair function // This can be called anytime to ensure the system is working properly verifyAndRepairSystemState() { console.log('[SYSTEM CHECK] Verifying system state and repairing if needed...'); const activeTab = this.tabs.find(t => t.id === this.activeTabId); if (!activeTab) { console.log('[SYSTEM CHECK] No active tab found'); return false; } if (activeTab.mode === 'media') { // Check if LocalMediaHandler exists if (!window.localMediaHandler) { console.log('[SYSTEM REPAIR] LocalMediaHandler missing - creating...'); this.localMediaHandler = new LocalMediaHandler(); window.localMediaHandler = this.localMediaHandler; } // Check if drawing canvas exists const drawingCanvas = document.getElementById(`mediaDrawingCanvas${activeTab.id}`); if (!drawingCanvas) { console.log('[SYSTEM REPAIR] Drawing canvas missing - initializing media tab...'); this.initializeMediaTab(activeTab.id); return true; } // Check if canvas has proper layering const canvasZIndex = parseInt(drawingCanvas.style.zIndex || '0'); const mediaItems = document.querySelectorAll('.canvas-media-item'); if (canvasZIndex !== 1000) { console.log('[SYSTEM REPAIR] Canvas z-index incorrect - fixing layering...'); drawingCanvas.style.zIndex = '1000'; drawingCanvas.style.setProperty('z-index', '1000', 'important'); } // Ensure all media items have correct z-index mediaItems.forEach(item => { const itemZIndex = parseInt(item.style.zIndex || '0'); if (itemZIndex !== 2) { console.log('[SYSTEM REPAIR] Media item z-index incorrect - fixing...'); item.style.zIndex = '2'; item.style.setProperty('z-index', '2', 'important'); } }); // Check if current tool is working properly if (window.currentTool === 'select' && window.selectModeActive) { const hasInteractiveMedia = Array.from(mediaItems).some(item => item.style.pointerEvents === 'auto' || item.style.getPropertyValue('pointer-events') === 'auto' ); const hasCorrectCursor = Array.from(mediaItems).some(item => item.style.cursor === 'move' || item.style.getPropertyValue('cursor') === 'move' ); if (!hasInteractiveMedia && mediaItems.length > 0) { console.log('[SYSTEM REPAIR] Select tool not working - refreshing interactions...'); if (window.localMediaHandler && window.localMediaHandler.refreshMediaInteractions) { window.localMediaHandler.refreshMediaInteractions(); } } if (!hasCorrectCursor && mediaItems.length > 0) { console.log('[SYSTEM REPAIR] Select tool cursor missing - applying immediately...'); this.applySelectToolCursorImmediate(); } } } console.log('[SYSTEM CHECK] System state verification complete'); return true; } createNewTab() { if (!isAutomaticOperation) { pulseCompanionActionFeedback('new_bible_tab'); } const tabId = this.generateUniqueTabId(Date.now()); const tab = { id: tabId, book: '', // Start with no book selected chapter: null, // Start with no chapter selected verse: null, // Start with no verse selected verseEnd: null, // Start with no verse end selected translation: 'ESV', loaded: false, mode: 'bible', // Default to bible mode mediaContent: null, // For storing media content mediaTitle: null, // For storing media title isSelectingVerses: false, // Flag to track verse selection state verseSelectionCooldown: 0 // Timestamp to prevent immediate verse selection }; // Don't record tab creation - UI state changes shouldn't be undoable this.tabs.push(tab); // Route activation through normal switch flow so pen color/tool state // updates exactly as if the user tapped the tab. this.switchTab(tabId); // CRITICAL FIX: If this is a media tab, ensure interactions are set up if (tab.mode === 'media') { setTimeout(() => { if (window.ensureMediaInteractions) { window.ensureMediaInteractions(); console.log('[DEBUG] Ensured media interactions for new media tab'); } }, 200); } // Don't automatically load passage - let user choose when to load } // Announcements tab (image-first, scrollable) createAnnouncementsTab() { // Ensure this tab sorts before whiteboard/notes by using an earlier timestamp const tabId = this.generateUniqueTabId(Date.now() - 20); const tab = { id: tabId, book: '', chapter: null, verse: null, verseEnd: null, translation: 'ESV', loaded: true, mode: 'announcements', mediaContent: null, mediaTitle: 'Announcements', mediaImageName: null, isSelectingVerses: false, verseSelectionCooldown: 0 }; this._insertGeneralTab(tab); this.renderTabs(); } // Graphics tab (image-first, swipeable carousel). Mirrors Announcements but // sources its images from the Graphics/ directory via /api/graphics/list. createGraphicsTab() { // Sort directly after Announcements (-20) and before Whiteboard (-10) const tabId = this.generateUniqueTabId(Date.now() - 15); const tab = { id: tabId, book: '', chapter: null, verse: null, verseEnd: null, translation: 'ESV', loaded: true, mode: 'graphics', mediaContent: null, mediaTitle: 'Graphics', mediaImageName: null, isSelectingVerses: false, verseSelectionCooldown: 0 }; this._insertGeneralTab(tab); this.renderTabs(); } // Maps & Timelines tab: same image carousel as Graphics, sourced from /Maps/ via /api/maps/list. createMapsTab() { // Sort directly after Graphics (-15) and before Whiteboard (-10) const tabId = this.generateUniqueTabId(Date.now() - 14); const tab = { id: tabId, book: '', chapter: null, verse: null, verseEnd: null, translation: 'ESV', loaded: true, mode: 'maps', mediaContent: null, mediaTitle: 'Maps', mediaImageName: null, isSelectingVerses: false, verseSelectionCooldown: 0 }; this._insertGeneralTab(tab); this.renderTabs(); } createWhiteboardTab() { if (!isAutomaticOperation) { pulseCompanionActionFeedback('new_whiteboard_tab'); } const tabId = this.generateUniqueTabId(Date.now() - 10); // ensure it sorts before others and is unique const tab = { id: tabId, book: '', chapter: null, verse: null, verseEnd: null, translation: 'ESV', loaded: true, mode: 'whiteboard', mediaContent: null, mediaTitle: null, isSelectingVerses: false, verseSelectionCooldown: 0, whiteboardHeight: Math.max(window.innerHeight * 2, 4000) }; // Do not set activeTabId here; Bible tab will become active after creation this._insertGeneralTab(tab); this.renderTabs(); // Do not render content now; active tab content will be rendered after Bible tab is created } // Notes tab (fixed mode): renders a two-page PDF background; annotations enabled createNotesTab() { if (!isAutomaticOperation) { pulseCompanionActionFeedback('new_notes_tab'); } // Ensure a unique ID distinct from the subsequently created Bible tab const tabId = this.generateUniqueTabId(Date.now() - 5); // ensure unique and earlier than bible tab const tab = { id: tabId, book: '', chapter: null, verse: null, verseEnd: null, translation: 'ESV', loaded: true, mode: 'notes', mediaContent: null, mediaTitle: null, isSelectingVerses: false, verseSelectionCooldown: 0 }; this._insertGeneralTab(tab); this.renderTabs(); } // Alpha tab (fixed mode): dedicated select tool tab that remembers bible passage createAlphaTab() { // Ensure a unique ID between notes (-5) and bible (0) const tabId = this.generateUniqueTabId(Date.now() - 2); // ensure unique and between notes and bible const tab = { id: tabId, book: '', chapter: null, verse: null, verseEnd: null, translation: 'ESV', loaded: false, mode: 'alpha', mediaContent: null, mediaTitle: null, isSelectingVerses: false, verseSelectionCooldown: 0, // Store the passage from the bible tab we switched from _rememberedPassage: null }; this._insertGeneralTab(tab); this.renderTabs(); } // Prayer tab: text editor for prayers and notes createPrayerTab() { const tabId = this.generateUniqueTabId(Date.now() - 3); // ensure unique and between notes and alpha const tab = { id: tabId, book: '', chapter: null, verse: null, verseEnd: null, translation: 'ESV', loaded: true, mode: 'prayer', mediaContent: null, mediaTitle: null, isSelectingVerses: false, verseSelectionCooldown: 0, prayerContent: '' // Store prayer text content }; this._insertGeneralTab(tab); this.renderTabs(); } // Ask tab: offline-capable AI assistant with voice input + streaming text answer. // Uses Ollama (localhost:11434) by default; can fall back to user-configured API // providers stored in Settings. Math is evaluated locally for guaranteed accuracy. createAskTab() { const tabId = this.generateUniqueTabId(Date.now() - 1); // sort just before bible tabs const tab = { id: tabId, book: '', chapter: null, verse: null, verseEnd: null, translation: 'ESV', loaded: true, mode: 'ask', mediaContent: null, mediaTitle: 'Ask', isSelectingVerses: false, verseSelectionCooldown: 0, askMessages: [] // [{ role: 'user'|'assistant', content: string, ts: number }] }; this._insertGeneralTab(tab); this.renderTabs(); } // ProPresenter Sync tab: a REAL Bible tab (mode 'bible' — drawing, scrolling, popups, annotations, // selectors all behave exactly as on any Bible tab) that propresenter-sync.js moves to whatever // scripture is live in ProPresenter. The `ppsync` flag is the only difference and it decides just // four things: the icon, the position (always the last non-Bible tab, immediately left of the first // Bible tab — normalizeTabOrder), no close control (closeTab / showCloseConfirm), and its own // visibility switch. Nothing on the tab or panel shows the link state — that is read in Settings and // on Companion buttons. createPPSyncTab() { if (this.tabs.some(t => t.ppsync)) return; // exactly one const tabId = this.generateUniqueTabId(Date.now() - 1); const tab = { id: tabId, book: '', chapter: null, verse: null, verseEnd: null, translation: 'ESV', loaded: false, mode: 'bible', ppsync: true, mediaContent: null, mediaTitle: 'ProPresenter Sync', isSelectingVerses: false, verseSelectionCooldown: 0 }; this.tabs.push(tab); this.normalizeTabOrder(); this.renderTabs(); } // Non-Bible tabs created later (the + menu) go in front of the sync tab, so it stays the last general tab. _insertGeneralTab(tab) { const i = this.tabs.findIndex(t => t.ppsync); if (i >= 0) this.tabs.splice(i, 0, tab); else this.tabs.push(tab); } // Position rule for the sync tab: immediately left of the first Bible tab (end of the strip if there is // none). Runs on every render, so neither new tabs nor a drag can displace it. normalizeTabOrder() { const i = this.tabs.findIndex(t => t.ppsync); if (i < 0) return; const sync = this.tabs[i]; const rest = this.tabs.filter(t => t !== sync); const fb = rest.findIndex(t => t.mode === 'bible' && !t.ppsync); rest.splice(fb < 0 ? rest.length : fb, 0, sync); for (let k = 0; k < rest.length; k++) { if (this.tabs[k] !== rest[k]) { this.tabs = rest; return; } } } // Initialize prayer tab with keyboard and event handlers initializePrayerTab(tabId) { const prayerTextEl = document.getElementById(`prayerText${tabId}`); const keyboardEl = document.getElementById(`prayerKeyboard${tabId}`); if (!prayerTextEl || !keyboardEl) return; const tab = this.tabs.find(t => t.id === tabId); if (!tab) return; // Disable drag behavior on prayer text to allow normal text selection prayerTextEl.setAttribute('draggable', 'false'); prayerTextEl.addEventListener('dragstart', (e) => { e.preventDefault(); e.stopPropagation(); return false; }); prayerTextEl.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); return false; }); prayerTextEl.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); return false; }); prayerTextEl.addEventListener('dragenter', (e) => { e.preventDefault(); e.stopPropagation(); return false; }); prayerTextEl.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); return false; }); prayerTextEl.addEventListener('dragend', (e) => { e.preventDefault(); e.stopPropagation(); return false; }); // Create draggable keyboard this.createPrayerKeyboard(keyboardEl, prayerTextEl, tabId); // Load saved content (preserve HTML formatting) // Only load if there's actual content (not empty string) and element is empty try { const saved = getPrayerContentSnapshot(tab); // Check if saved has actual content (not empty string or just whitespace) if (saved && saved.trim() && !prayerTextEl.innerHTML.trim()) { // Check if saved content is HTML or plain text if (saved.includes('<') && saved.includes('>')) { prayerTextEl.innerHTML = saved; } else { // Plain text - convert to HTML preserving line breaks prayerTextEl.innerHTML = saved.replace(/\n/g, '
'); } tab.prayerContent = saved; persistPrayerContent(tabId, saved); } else if (!saved || !saved.trim()) { // If localStorage is empty or whitespace, ensure element and tab are also empty prayerTextEl.innerHTML = ''; tab.prayerContent = ''; } } catch (_) {} // Handle input events - save content (preserve HTML formatting) let saveTimeout; const clearPrayerSaveTimeout = () => { try { if (saveTimeout) clearTimeout(saveTimeout); if (tab._prayerSaveTimeout) clearTimeout(tab._prayerSaveTimeout); } catch (_) {} saveTimeout = null; tab._prayerSaveTimeout = null; }; const flushPrayerSave = () => { clearPrayerSaveTimeout(); const content = prayerTextEl.innerHTML || ''; tab.prayerContent = content; persistPrayerContent(tabId, content, true); }; prayerTextEl.addEventListener('input', () => { const content = prayerTextEl.innerHTML || ''; tab.prayerContent = content; clearPrayerSaveTimeout(); saveTimeout = setTimeout(() => { persistPrayerContent(tabId, content, true); }, 500); tab._prayerSaveTimeout = saveTimeout; }); prayerTextEl.addEventListener('blur', flushPrayerSave); // Handle paste - strip formatting prayerTextEl.addEventListener('paste', (e) => { e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData('text/plain'); document.execCommand('insertText', false, text); }); // Show keyboard on focus prayerTextEl.addEventListener('focus', () => { keyboardEl.style.display = 'block'; }); // Optional: hide keyboard on blur (commented out so it stays visible) // prayerTextEl.addEventListener('blur', () => { // keyboardEl.style.display = 'none'; // }); } // Create draggable popup keyboard createPrayerKeyboard(keyboardEl, textEl, tabId) { // Traditional QWERTY layout: Tab leads the Q-row, Shift leads the Z-row, // Backspace tails the Z-row, and Space + Enter share the action row — // matching a real keyboard so muscle memory works. const rows = [ ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], ['tab', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'], ['shift', 'z', 'x', 'c', 'v', 'b', 'n', 'm', 'backspace'], // Punctuation row gains an em-dash (\u2014) and a bullet (\u2022). [',', '.', '?', '!', ';', ':', '-', '\u2014', '\u2022', "'", '"', '(', ')'], ['space', 'enter'] ]; // Shift mappings for numbers const numberShiftMap = { '1': '!', '2': '@', '3': '#', '4': '$', '5': '%', '6': '^', '7': '&', '8': '*', '9': '(', '0': ')' }; // Special-key glyphs. Use proper Unicode (was rendering as garbled "?" // placeholders before). const SHIFT_GLYPH = '\u21E7'; // \u21E7 const CAPS_GLYPH = '\u21EA'; // \u21EA (used when shift is locked) const TAB_GLYPH = '\u21E5'; // \u21E5 const BKSP_GLYPH = '\u232B'; // \u232B const ENTER_GLYPH = '\u23CE'; // \u23CE const CLOSE_GLYPH = '\u00D7'; // \u00D7 // Double-tap window for Shift -> Caps Lock. const SHIFT_DOUBLE_TAP_MS = 400; // HTML-escape helpers: the punctuation row includes the " character, // which would otherwise break `data-key="..."` and render as an empty key. const escAttr = (s) => String(s) .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); const escText = (s) => String(s) .replace(/&/g, '&') .replace(//g, '>'); // Build keyboard HTML with formatting toolbar let keyboardHTML = '
'; keyboardHTML += 'Keyboard'; keyboardHTML += '
'; keyboardHTML += ''; keyboardHTML += ''; keyboardHTML += ''; // Align left icon (three lines: short, long, medium - all left-aligned) keyboardHTML += ''; // Align center icon (three equal lines - all centered) keyboardHTML += ''; // Align right icon (three lines: medium, long, short - all right-aligned) keyboardHTML += ''; // Copy icon (overlapping squares) keyboardHTML += ''; // Paste icon (clipboard) keyboardHTML += ''; keyboardHTML += '
'; keyboardHTML += ``; keyboardHTML += '
'; keyboardHTML += '
'; rows.forEach((row, rowIdx) => { keyboardHTML += '
'; row.forEach(key => { if (key === 'space') { keyboardHTML += ''; } else if (key === 'backspace') { keyboardHTML += ``; } else if (key === 'enter') { keyboardHTML += ``; } else if (key === 'shift') { keyboardHTML += ``; } else if (key === 'tab') { keyboardHTML += ``; } else { keyboardHTML += ``; } }); keyboardHTML += '
'; }); keyboardHTML += '
'; keyboardEl.innerHTML = keyboardHTML; // Position keyboard centered on screen initially. Width matches the CSS // rule (.prayer-keyboard width: 760px) — keep these two in sync. const KEYBOARD_DEFAULT_WIDTH = 760; const KEYBOARD_DEFAULT_HEIGHT = 340; const centerX = Math.max(0, (window.innerWidth - KEYBOARD_DEFAULT_WIDTH) / 2); const centerY = Math.max(0, (window.innerHeight - KEYBOARD_DEFAULT_HEIGHT) / 2); keyboardEl.style.left = `${centerX}px`; keyboardEl.style.top = `${centerY}px`; // Make keyboard draggable let isDragging = false; let dragStartX = 0; let dragStartY = 0; let startLeft = 0; let startTop = 0; const header = keyboardEl.querySelector('.prayer-keyboard-header'); const isHeaderControl = (target) => { return !!(target && target.closest && target.closest('.prayer-format-btn, .prayer-keyboard-close')); }; if (header) { header.style.cursor = 'move'; header.addEventListener('mousedown', (e) => { if (isHeaderControl(e.target)) return; if (typeof e.button === 'number' && e.button !== 0) return; isDragging = true; dragStartX = e.clientX; dragStartY = e.clientY; const rect = keyboardEl.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; e.preventDefault(); }); header.addEventListener('touchstart', (e) => { if (isHeaderControl(e.target)) return; if (!e.touches || e.touches.length !== 1) return; isDragging = true; const touch = e.touches[0]; dragStartX = touch.clientX; dragStartY = touch.clientY; const rect = keyboardEl.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; e.preventDefault(); }); } const handleMove = (clientX, clientY) => { if (!isDragging) return; const deltaX = clientX - dragStartX; const deltaY = clientY - dragStartY; let newLeft = startLeft + deltaX; let newTop = startTop + deltaY; // Keep keyboard within viewport bounds const keyboardWidth = keyboardEl.offsetWidth || KEYBOARD_DEFAULT_WIDTH; const keyboardHeight = keyboardEl.offsetHeight || KEYBOARD_DEFAULT_HEIGHT; newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - keyboardWidth)); newTop = Math.max(0, Math.min(newTop, window.innerHeight - keyboardHeight)); keyboardEl.style.left = `${newLeft}px`; keyboardEl.style.top = `${newTop}px`; }; // Remove any previously-registered prayer-keyboard document listeners so they don't // stack on every re-render (the keyboard DOM is rebuilt each time the prayer tab renders). if (this._prayerKbHandlers) { document.removeEventListener('mousemove', this._prayerKbHandlers.mousemove); document.removeEventListener('touchmove', this._prayerKbHandlers.touchmove); document.removeEventListener('mouseup', this._prayerKbHandlers.mouseup); document.removeEventListener('touchend', this._prayerKbHandlers.touchend); if (this._prayerKbHandlers.selectionchange) document.removeEventListener('selectionchange', this._prayerKbHandlers.selectionchange); } const onPrayerMouseMove = (e) => { if (isDragging) { handleMove(e.clientX, e.clientY); e.preventDefault(); } }; const onPrayerTouchMove = (e) => { if (isDragging) { const touch = e.touches[0]; handleMove(touch.clientX, touch.clientY); e.preventDefault(); } }; const onPrayerMouseUp = () => { isDragging = false; }; const onPrayerTouchEnd = () => { isDragging = false; }; document.addEventListener('mousemove', onPrayerMouseMove); document.addEventListener('touchmove', onPrayerTouchMove); document.addEventListener('mouseup', onPrayerMouseUp); document.addEventListener('touchend', onPrayerTouchEnd); this._prayerKbHandlers = { mousemove: onPrayerMouseMove, touchmove: onPrayerTouchMove, mouseup: onPrayerMouseUp, touchend: onPrayerTouchEnd }; // Three-state shift: 'off' | 'on' | 'locked' (caps lock). // 'off' -> letters lowercase, numbers as-is // 'on' -> letters uppercase, numbers map to shift symbols; auto-releases after one key // 'locked' -> letters uppercase ONLY; numbers/punctuation behave as 'off' (matches a real Caps Lock). // Activate caps lock by double-tapping Shift; a single tap while locked releases. let shiftState = 'off'; let lastShiftTapMs = 0; const shiftIsHot = () => shiftState !== 'off'; const numberShiftActive = () => shiftState === 'on'; const updateKeyDisplay = () => { keyboardEl.querySelectorAll('.prayer-key-btn').forEach(k => { const key = k.dataset.key; if (!key || key.length !== 1) return; if (/[a-z]/.test(key)) { // Letters follow shift OR caps lock k.textContent = shiftIsHot() ? key.toUpperCase() : key; } else if (/[0-9]/.test(key)) { // Numbers only show shift symbol when Shift is momentarily on (NOT when caps-locked) if (numberShiftActive() && k.dataset.shift) { k.textContent = k.dataset.shift; } else { k.textContent = key; } } }); }; const updateShiftButton = () => { const shiftBtn = keyboardEl.querySelector('.prayer-key-shift'); if (!shiftBtn) return; shiftBtn.classList.toggle('prayer-key-active', shiftState === 'on'); shiftBtn.classList.toggle('prayer-key-locked', shiftState === 'locked'); // Swap label glyph + text to clearly indicate Caps Lock vs Shift. const glyphEl = shiftBtn.querySelector('.prayer-key-glyph'); if (glyphEl) glyphEl.textContent = shiftState === 'locked' ? CAPS_GLYPH : SHIFT_GLYPH; // Preserve the text label after the glyph span. const label = shiftState === 'locked' ? ' Caps' : ' Shift'; // Rebuild the trailing text node without touching the glyph span. let foundText = false; for (const node of Array.from(shiftBtn.childNodes)) { if (node.nodeType === Node.TEXT_NODE) { node.textContent = label; foundText = true; break; } } if (!foundText) shiftBtn.appendChild(document.createTextNode(label)); }; const setShiftState = (newState) => { if (shiftState === newState) return; shiftState = newState; updateShiftButton(); updateKeyDisplay(); }; // Try to insert text, falling back to native value mutation if execCommand is blocked. const insertTextInto = (text) => { try { if (document.execCommand('insertText', false, text)) return true; } catch (_) {} try { const sel = window.getSelection(); if (sel && sel.rangeCount) { const range = sel.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); return true; } } catch (_) {} return false; }; keyboardEl.querySelectorAll('.prayer-key-btn').forEach(btn => { btn.addEventListener('click', () => { const key = btn.dataset.key; if (key === 'backspace') { document.execCommand('delete', false); } else if (key === 'enter') { insertTextInto('\n'); } else if (key === 'tab') { insertTextInto('\t'); } else if (key === 'shift') { const now = Date.now(); if (shiftState === 'locked') { // Single tap while locked releases. setShiftState('off'); } else if (shiftState === 'on' && (now - lastShiftTapMs) <= SHIFT_DOUBLE_TAP_MS) { // Double-tap: lock caps. setShiftState('locked'); } else if (shiftState === 'on') { setShiftState('off'); } else { setShiftState('on'); } lastShiftTapMs = now; } else { // Determine the text to insert based on current shift/caps state. let textToInsert = key; if (/[a-z]/.test(key) && shiftIsHot()) { textToInsert = key.toUpperCase(); } else if (/[0-9]/.test(key) && numberShiftActive() && btn.dataset.shift) { textToInsert = btn.dataset.shift; } insertTextInto(textToInsert); // Auto-release momentary Shift after any character key — but never auto-release Caps Lock. if (shiftState === 'on') { setShiftState('off'); } } textEl.focus(); }); }); // Handle formatting button clicks keyboardEl.querySelectorAll('.prayer-format-btn').forEach(btn => { btn.addEventListener('click', () => { const format = btn.dataset.format; textEl.focus(); if (format === 'bold') { document.execCommand('bold', false); } else if (format === 'italic') { document.execCommand('italic', false); } else if (format === 'highlight') { // Toggle highlight - check if selection already has yellow background const currentBgColor = document.queryCommandValue('backColor'); const hasHighlight = currentBgColor === '#ffff00' || currentBgColor === 'rgb(255, 255, 0)' || currentBgColor === 'yellow' || (currentBgColor && currentBgColor.toLowerCase().includes('rgb(255, 255, 0)')); if (hasHighlight) { // Remove highlight by setting background to transparent document.execCommand('backColor', false, 'transparent'); } else { // Apply highlight document.execCommand('backColor', false, '#ffff00'); } } else if (format === 'copy') { // Copy selected text or all text if nothing selected if (window.getSelection().toString()) { document.execCommand('copy'); } else { // Copy all text const range = document.createRange(); range.selectNodeContents(textEl); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.execCommand('copy'); selection.removeAllRanges(); } } else if (format === 'paste') { // Paste from clipboard navigator.clipboard.readText().then(text => { document.execCommand('insertText', false, text); }).catch(() => { // Fallback for older browsers document.execCommand('paste'); }); } else if (format === 'align-left') { document.execCommand('justifyLeft', false); // Update alignment button states keyboardEl.querySelectorAll('[data-format^="align-"]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); } else if (format === 'align-center') { document.execCommand('justifyCenter', false); // Update alignment button states keyboardEl.querySelectorAll('[data-format^="align-"]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); } else if (format === 'align-right') { document.execCommand('justifyRight', false); // Update alignment button states keyboardEl.querySelectorAll('[data-format^="align-"]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); } // Update button states updateFormatButtonStates(keyboardEl, textEl); }); }); // Update format button states based on current selection const updateFormatButtonStates = (kbEl, txtEl) => { const boldBtn = kbEl.querySelector('[data-format="bold"]'); const italicBtn = kbEl.querySelector('[data-format="italic"]'); const highlightBtn = kbEl.querySelector('[data-format="highlight"]'); if (boldBtn) { boldBtn.classList.toggle('active', document.queryCommandState('bold')); } if (italicBtn) { italicBtn.classList.toggle('active', document.queryCommandState('italic')); } if (highlightBtn) { // Check if selection has yellow background const hasHighlight = document.queryCommandValue('backColor') === '#ffff00' || document.queryCommandValue('backColor') === 'rgb(255, 255, 0)'; highlightBtn.classList.toggle('active', hasHighlight); } }; // Update button states on selection change textEl.addEventListener('input', () => updateFormatButtonStates(keyboardEl, textEl)); textEl.addEventListener('mouseup', () => { setTimeout(() => updateFormatButtonStates(keyboardEl, textEl), 10); }); textEl.addEventListener('keyup', () => { setTimeout(() => updateFormatButtonStates(keyboardEl, textEl), 10); }); textEl.addEventListener('focus', () => { setTimeout(() => updateFormatButtonStates(keyboardEl, textEl), 10); }); // Use selectionchange event if available if (document.addEventListener) { const onPrayerSelectionChange = () => { if (document.activeElement === textEl) { updateFormatButtonStates(keyboardEl, textEl); } }; document.addEventListener('selectionchange', onPrayerSelectionChange); if (this._prayerKbHandlers) this._prayerKbHandlers.selectionchange = onPrayerSelectionChange; } // Keyboard shortcuts for formatting and copy/paste textEl.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) { if (e.key === 'b' || e.key === 'B') { e.preventDefault(); document.execCommand('bold', false); updateFormatButtonStates(keyboardEl, textEl); } else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); document.execCommand('italic', false); updateFormatButtonStates(keyboardEl, textEl); } else if (e.key === 'c' || e.key === 'C') { // Allow default copy behavior, but update button states after setTimeout(() => updateFormatButtonStates(keyboardEl, textEl), 10); } else if (e.key === 'v' || e.key === 'V') { // Allow default paste behavior, but update button states after setTimeout(() => updateFormatButtonStates(keyboardEl, textEl), 10); } } }); } getTabVisibilitySettings() { try { const saved = localStorage.getItem('tabVisibility'); if (saved) { const parsed = JSON.parse(saved); // Backfill defaults so tabs added in newer builds (e.g. 'ask', 'graphics') // aren't hidden when older tabVisibility entries were saved. if (parsed && typeof parsed === 'object') { if (parsed.ask === undefined) parsed.ask = true; if (parsed.graphics === undefined) parsed.graphics = true; if (parsed.maps === undefined) parsed.maps = true; if (parsed.ppsync === undefined) parsed.ppsync = true; } return parsed; } } catch (e) { console.error('Failed to load tab visibility settings:', e); } // Default: all tabs visible return { bible: true, announcements: true, graphics: true, maps: true, whiteboard: true, notes: true, prayer: true, ask: true, ppsync: true }; } isTabVisible(tab) { const visibility = this.getTabVisibilitySettings(); if (tab.ppsync) return visibility.ppsync !== false; // its own switch (Settings › Tabs), not the Bible one // Default to true if not set return visibility[tab.mode] !== false; } renderTabs() { const tabBar = document.querySelector('.tab-bar'); tabBar.innerHTML = ''; try { this.normalizeTabOrder(); } catch (_) {} /* * ------------------------------------------------------------------------------------------ * The swipe-down close button (the little x under a tab). * * It is a FIXED overlay, because the tab bar clips anything drawn inside a tab. It used to be * born inside its tab and get moved to each time it was shown and moved back when it was * hidden — and that shuttling is what made the x land in the tab row, too high and too far left, * or not appear at all: * * - The move back was driven by 'transitionend'. That event fires for the SHOW fade too, so * swiping again inside the 180 ms fade-out tore down the button that was just being put up: * left/top were wiped while `position: fixed` stayed (it is in the stylesheet, not the * style attribute), so the button fell back to its static spot — inside the tab row, then * shifted half its width left by its own translate(-50%). That is the misplaced x, exactly. * - When no transition ran at all the event never came, and the button stayed parked on * . renderTabs() rebuilds the tab row wholesale, so the button it belonged to was * orphaned there while its tab got a brand new one — and the global swipe fallback, which * looked the button up with tabEl.querySelector, found nothing and did nothing. That is the * x that never appears. Reset cleared the orphans, which is why reset "fixed" it each time. * * So: the buttons now live permanently in one portal layer on , keyed to their tab, and * are never re-parented. Teardown is a plain timer that checks a generation counter, so a button * shown again can never be torn down by the previous hide. renderTabs() sweeps the layer, so a * rebuilt tab row cannot leave a stale button behind. Nothing depends on transitionend. * ------------------------------------------------------------------------------------------ */ const CLOSE_FADE_MS = 240; // comfortably past the 0.18s opacity transition in styles.css const closeLayer = () => { let layer = document.getElementById('tabCloseLayer'); if (!layer) { layer = document.createElement('div'); layer.id = 'tabCloseLayer'; document.body.appendChild(layer); } return layer; }; // Resolve a tab's close button through the portal — never tabEl.querySelector, which is what // silently found nothing once the button had been moved out of the tab. const overlayForTab = (tabEl) => { const id = (tabEl && tabEl.dataset) ? String(tabEl.dataset.tabId || '') : ''; if (!id) return null; const layer = closeLayer(); for (let i = 0; i < layer.children.length; i++) { const el = layer.children[i]; if (String(el.dataset.tabId || '') === id) return el; } return null; }; const forgetCloseOverlay = (el) => { try { if (el._hideTimer) { clearTimeout(el._hideTimer); el._hideTimer = null; } if (el._teardownTimer) { clearTimeout(el._teardownTimer); el._teardownTimer = null; } if (el._trackRaf) { cancelAnimationFrame(el._trackRaf); el._trackRaf = 0; } el.remove(); } catch (_) {} }; // The tab row is about to be rebuilt, so every button in the layer belongs to a tab element that // is about to stop existing. Clear them out; each tab makes its own again below. try { const layer = closeLayer(); while (layer.firstChild) forgetCloseOverlay(layer.firstChild); } catch (_) {} /* * Put the button just under its tab, horizontally centred on it and kept inside the screen. * Returns false when the tab has no box to hang off (mid-render, hidden, detached) — the caller * then leaves the button down rather than showing it somewhere arbitrary. */ const positionCloseOverlay = (tabEl, overlayEl) => { if (!tabEl || !overlayEl) return false; try { const rect = tabEl.getBoundingClientRect(); if (!rect.width && !rect.height) return false; const half = (overlayEl.offsetWidth || 36) / 2; const minX = half + 2; const maxX = Math.max(minX, window.innerWidth - half - 2); const x = Math.min(Math.max(rect.left + rect.width / 2 + 3, minX), maxX); overlayEl.style.position = 'fixed'; overlayEl.style.left = `${Math.round(x)}px`; overlayEl.style.top = `${Math.round(rect.bottom + 6)}px`; // place just below the tab overlayEl.style.zIndex = '100000'; return true; } catch (_) { return false; } }; // While it is up, keep it under its tab: the row can scroll, reflow or resize underneath it. const stopCloseOverlayTracking = (overlayEl) => { if (overlayEl && overlayEl._trackRaf) { cancelAnimationFrame(overlayEl._trackRaf); overlayEl._trackRaf = 0; } }; const startCloseOverlayTracking = (tabEl, overlayEl) => { stopCloseOverlayTracking(overlayEl); const tick = () => { overlayEl._trackRaf = 0; if (!overlayEl.classList.contains('show') || !overlayEl.isConnected || !tabEl.isConnected) return; positionCloseOverlay(tabEl, overlayEl); overlayEl._trackRaf = requestAnimationFrame(tick); }; overlayEl._trackRaf = requestAnimationFrame(tick); }; // Filter tabs based on visibility settings const visibleTabs = this.tabs.filter(tab => this.isTabVisible(tab)); // If active tab is hidden, switch to first visible tab if (this.activeTabId) { const activeTab = this.tabs.find(t => t.id === this.activeTabId); if (activeTab && !this.isTabVisible(activeTab)) { if (visibleTabs.length > 0) { this.switchTab(visibleTabs[0].id); return; // avoid double-render/duplicate tabs } else { // No visible tabs - create a new bible tab this.createNewTab(); return; // renderTabs will be called again by createNewTab } } } visibleTabs.forEach(tab => { const tabElement = document.createElement('div'); tabElement.className = 'tab' + (tab.id === this.activeTabId ? ' active' : ''); tabElement.dataset.tabId = tab.id; let iconHTML = ''; if (tab.mode === 'bible' && !tab.ppsync) { iconHTML = ``; } else if (tab.mode === 'notes') { iconHTML = ``; } else if (tab.mode === 'announcements') { iconHTML = ``; } else if (tab.mode === 'graphics') { iconHTML = ``; } else if (tab.mode === 'maps') { iconHTML = ``; } else if (tab.mode === 'prayer') { iconHTML = ``; } else if (tab.mode === 'alpha') { iconHTML = ``; } else if (tab.mode === 'whiteboard') { iconHTML = ``; } else if (tab.ppsync) { // Broadcast glyph: ProPresenter Sync. Nothing else — no state dot: the link state is read in // Settings › ProPresenter Sync and on Companion buttons, never on the stage screen. tabElement.classList.add('ppsync-tab'); tabElement.title = 'ProPresenter Sync'; iconHTML = ``; } else if (tab.mode === 'ask') { // Microphone glyph in a rounded square — voice-driven AI assistant iconHTML = ``; } else { iconHTML = ``; } tabElement.innerHTML = ` ${iconHTML} `; // Cross-platform tab click handling with aggressive anti-highlight for touch const handleTabSwitch = (e) => { // A reorder drag ends in a click; never let that switch tabs. if (tabElement.__suppressNextClick) { tabElement.__suppressNextClick = false; return; } /* * The swipe-down that reveals the close button also emits a click. Suppress only * THAT click, by time — never with a sticky flag. * * The sticky flag was the bug: a swipe past the browser's click-cancel distance * emits no click at all, so the flag survived and ate the user's next deliberate * tap. Combined with a 20px trigger on a 2160x3840 panel — where an ordinary tap * drifts further than that — every attempt to tap into the tab re-revealed the * close button and re-armed the flag, so the tab was unreachable until the overlay * timed out on its own. */ if (tabElement.__suppressClickUntil && Date.now() < tabElement.__suppressClickUntil) { tabElement.__suppressClickUntil = 0; return; } tabElement.__suppressClickUntil = 0; // Tapping a tab whose close button is up dismisses the button and switches, instead // of forcing the user to wait the overlay out. if (closeConfirm.classList.contains('show')) { hideCloseConfirm(true); } e.preventDefault(); e.stopPropagation(); // Active tab: only allow media <-> bible toggle; never toggle whiteboard, notes, or alpha if (tab.id === this.activeTabId) { if (tab.mode === 'media') { this.toggleTabMode(tab.id); } // If bible, whiteboard, notes, or alpha, do nothing on re-click } else { this.switchTab(tab.id); } }; // Touch device optimization with proper color preservation if (isTouchDevice) { // Reset touch styles while preserving proper tab colors const resetTabStyles = () => { // Clear problematic styles that cause visual artifacts tabElement.style.background = ''; tabElement.style.backgroundColor = ''; tabElement.style.border = ''; tabElement.style.boxShadow = ''; tabElement.style.transform = ''; tabElement.style.opacity = ''; tabElement.style.filter = ''; // CRITICAL: Explicitly preserve tab text color for touch devices const tabTitle = tabElement.querySelector('.tab-title'); if (tabTitle) { tabTitle.style.setProperty('color', '#e0e0e0', 'important'); } // Preserve tab background as transparent (default) tabElement.style.setProperty('background', 'transparent', 'important'); // Preserve active tab styling if this is the active tab if (tab.id === this.activeTabId) { tabElement.style.setProperty('border-bottom', '3px solid #4a9eff', 'important'); } }; // Prevent any visual feedback on touch tabElement.addEventListener('touchstart', (e) => { // Allow default/propagation when interacting with close UI if (e.target && (e.target.closest('.tab-close-btn') || e.target.closest('.tab-close-confirm'))) { return; } // Avoid preventing default to let swipe/hold gestures work; just keep visuals clean resetTabStyles(); }); tabElement.addEventListener('touchmove', (e) => { if (e.target && (e.target.closest('.tab-close-btn') || e.target.closest('.tab-close-confirm'))) { return; } // Avoid preventing default to let swipe/hold gestures work; just keep visuals clean resetTabStyles(); }); tabElement.addEventListener('touchend', (e) => { // If interacting with close UI, don't intercept if (e.target && (e.target.closest('.tab-close-btn') || e.target.closest('.tab-close-confirm'))) { resetTabStyles(); setTimeout(resetTabStyles, 10); return; } // Do not prevent default or stop propagation; rely on click for tab switching resetTabStyles(); setTimeout(resetTabStyles, 10); // If we just showed close/reordered, click will be suppressed by __suppressNextClick }); tabElement.addEventListener('touchcancel', () => { resetTabStyles(); setTimeout(resetTabStyles, 10); }); tabElement.addEventListener('pointercancel', (e) => { if (e.pointerType === 'touch') { resetTabStyles(); setTimeout(resetTabStyles, 10); } }); // Also prevent mouse events that might be triggered by touch tabElement.addEventListener('mouseenter', resetTabStyles); tabElement.addEventListener('mouseleave', resetTabStyles); tabElement.addEventListener('mousedown', resetTabStyles); tabElement.addEventListener('mouseup', resetTabStyles); } // Always add click handler for both touch and non-touch devices tabElement.addEventListener('click', handleTabSwitch); // Optimize for touch devices tabElement.style.touchAction = 'manipulation'; tabElement.style.webkitUserSelect = 'none'; tabElement.style.userSelect = 'none'; tabElement.style.webkitTouchCallout = 'none'; tabElement.style.transition = 'none'; // Disable transitions on touch devices // --- New Tab Interactions --- // Long-press to reorder; swipe down to reveal a close button const HOLD_TO_REORDER_MS = 400; const SWIPE_DOWN_THRESHOLD = 20; // easier to trigger on TV const HORIZONTAL_CANCEL_FOR_SWIPE = 35; // allow more drift on TV const CLOSE_HIDE_DELAY_MS = 1500; // auto-hide close overlay delay const DRAG_OPACITY = 0.9; /* * The close button, in the portal layer and keyed to this tab (see the block above). * The sync tab is fixed and has no close control, so it simply never gets one - which also * means the global swipe fallback cannot find one to put up on it. */ const closeConfirm = document.createElement('div'); closeConfirm.className = 'tab-close-confirm'; closeConfirm.dataset.tabId = String(tab.id); closeConfirm.style.display = 'none'; closeConfirm.innerHTML = ``; if (!tab.ppsync) closeLayer().appendChild(closeConfirm); const closeBtn = closeConfirm.querySelector('.tab-close-btn'); closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); hideCloseConfirm(true); this.closeTab(tab.id); }); let holdTimer = null; let startX = 0, startY = 0; let isReordering = false; let swipeDownTriggered = false; let currentDx = 0; let mouseSwipeTriggered = false; let mouseStartX = 0, mouseStartY = 0; // Placeholder-based live-reorder helpers let rafId = 0; let placeholder = null; let tabBarElRef = null; let barRect = null; let draggedWidth = 0; let draggedHeight = 0; let dragStartLeft = 0; let lastPlaceholderIndex = -1; const DEADZONE_PX = 28; let lastPointerX = null; let lastPointerY = null; let gestureActive = false; let activePointerId = null; let activeTouchId = null; let globalDragInstalled = false; let gestureSource = null; const findTouchById = (touchList, id) => { if (!touchList) return null; if (id == null) return touchList[0] || null; for (let i = 0; i < touchList.length; i++) { const t = touchList[i]; if (t && t.identifier === id) return t; } return null; }; const attachGlobalDragHandlers = () => { if (globalDragInstalled) return; globalDragInstalled = true; window.addEventListener('pointermove', onGlobalPointerMove, { passive: true, capture: true }); window.addEventListener('pointerup', onGlobalPointerUp, { passive: true, capture: true }); window.addEventListener('pointercancel', onGlobalPointerUp, { passive: true, capture: true }); window.addEventListener('mousemove', onGlobalMouseMove, { passive: true, capture: true }); window.addEventListener('mouseup', onGlobalMouseUp, { passive: true, capture: true }); window.addEventListener('touchmove', onGlobalTouchMove, { passive: true, capture: true }); window.addEventListener('touchend', onGlobalTouchEnd, { passive: true, capture: true }); window.addEventListener('touchcancel', onGlobalTouchEnd, { passive: true, capture: true }); }; const detachGlobalDragHandlers = () => { if (!globalDragInstalled) return; globalDragInstalled = false; window.removeEventListener('pointermove', onGlobalPointerMove, { capture: true }); window.removeEventListener('pointerup', onGlobalPointerUp, { capture: true }); window.removeEventListener('pointercancel', onGlobalPointerUp, { capture: true }); window.removeEventListener('mousemove', onGlobalMouseMove, { capture: true }); window.removeEventListener('mouseup', onGlobalMouseUp, { capture: true }); window.removeEventListener('touchmove', onGlobalTouchMove, { capture: true }); window.removeEventListener('touchend', onGlobalTouchEnd, { capture: true }); window.removeEventListener('touchcancel', onGlobalTouchEnd, { capture: true }); }; /* * Tear the close overlay back down and return it to the tab. * `immediate` skips waiting on the fade-out transition — a tap that dismisses the * overlay must not leave a still-fading copy sitting over the tab bar. */ const hideCloseConfirm = (immediate) => { if (closeConfirm._hideTimer) { clearTimeout(closeConfirm._hideTimer); closeConfirm._hideTimer = null; } if (closeConfirm._teardownTimer) { clearTimeout(closeConfirm._teardownTimer); closeConfirm._teardownTimer = null; } closeConfirm.classList.remove('show'); stopCloseOverlayTracking(closeConfirm); /* * Park it out of the way once the fade has run. Stamped with the generation it was * hidden at: showing it again bumps the generation, so a teardown left over from the * previous time can never strip the button that is currently on screen. Its position is * deliberately left alone - an unpositioned fixed button falls back to its static spot, * which is what used to drop the x into the tab row. */ const gen = closeConfirm._gen || 0; const park = () => { closeConfirm._teardownTimer = null; if ((closeConfirm._gen || 0) !== gen) return; closeConfirm.style.display = 'none'; }; if (immediate) park(); else closeConfirm._teardownTimer = setTimeout(park, CLOSE_FADE_MS); }; const scheduleAutoHide = () => { if (closeConfirm._hideTimer) { clearTimeout(closeConfirm._hideTimer); closeConfirm._hideTimer = null; } closeConfirm._hideTimer = setTimeout(() => hideCloseConfirm(false), CLOSE_HIDE_DELAY_MS); }; // Mouse support: drag down or context/middle click to reveal close tabElement.addEventListener('mousedown', (e) => { if (e.button !== 0) return; // only left button for swipe gesture if (e.target && (e.target.closest('.tab-close-btn') || e.target.closest('.tab-close-confirm'))) { return; } mouseStartX = e.clientX; mouseStartY = e.clientY; mouseSwipeTriggered = false; }); tabElement.addEventListener('mousemove', (e) => { if (mouseSwipeTriggered) return; if (e.buttons !== 1) return; // only while holding left button if (e.target && (e.target.closest('.tab-close-btn') || e.target.closest('.tab-close-confirm'))) { return; } const dx = e.clientX - mouseStartX; const dy = e.clientY - mouseStartY; // Same guard as the touch path: with the close button already up, let the click // through to handleTabSwitch so it dismisses and switches instead of re-revealing. if (closeConfirm.classList.contains('show')) return; if (dy > SWIPE_DOWN_THRESHOLD && Math.abs(dx) < HORIZONTAL_CANCEL_FOR_SWIPE) { mouseSwipeTriggered = true; showCloseConfirm(); } }); tabElement.addEventListener('contextmenu', (e) => { if (e.target && (e.target.closest('.tab-close-btn') || e.target.closest('.tab-close-confirm'))) { return; } e.preventDefault(); showCloseConfirm(); }); tabElement.addEventListener('auxclick', (e) => { if (e.button === 1 || e.button === 2) { // middle or right click if (e.target && (e.target.closest('.tab-close-btn') || e.target.closest('.tab-close-confirm'))) { return; } e.preventDefault(); showCloseConfirm(); } }); // Lock/unlock page scrolling during tab interactions (drag/swap/close swipe) const lockScroll = (lock) => { try { if (lock) { if (!window._tabScrollLockCount) window._tabScrollLockCount = 0; window._tabScrollLockCount++; if (window._tabScrollLockCount === 1) { document.documentElement.style.overflow = 'hidden'; document.body.style.overflow = 'hidden'; document.documentElement.style.touchAction = 'none'; document.body.style.touchAction = 'none'; if (!window._tabScrollMoveBlocker) { window._tabScrollMoveBlocker = (e) => { try { e.preventDefault(); e.stopPropagation(); } catch (_) {} }; ['touchmove','wheel','mousewheel','DOMMouseScroll'].forEach(type => { window.addEventListener(type, window._tabScrollMoveBlocker, { passive: false, capture: true }); document.addEventListener(type, window._tabScrollMoveBlocker, { passive: false, capture: true }); }); } } } else { if (window._tabScrollLockCount) window._tabScrollLockCount = Math.max(0, window._tabScrollLockCount - 1); if (window._tabScrollLockCount === 0) { document.documentElement.style.overflow = ''; document.body.style.overflow = ''; document.documentElement.style.touchAction = ''; document.body.style.touchAction = ''; if (window._tabScrollMoveBlocker) { ['touchmove','wheel','mousewheel','DOMMouseScroll'].forEach(type => { window.removeEventListener(type, window._tabScrollMoveBlocker, { capture: true }); document.removeEventListener(type, window._tabScrollMoveBlocker, { capture: true }); }); window._tabScrollMoveBlocker = null; } } } } catch (_) {} }; const showCloseConfirm = () => { if (tab.ppsync) return; // the sync tab has no close control (it is fixed) if (!closeConfirm.isConnected) return; // the row was re-rendered; this tab has a newer button // Swallow only the click this same gesture is about to emit (see handleTabSwitch). tabElement.__suppressClickUntil = Date.now() + 400; // New generation: any teardown still pending from the last time is now void. closeConfirm._gen = (closeConfirm._gen || 0) + 1; if (closeConfirm._hideTimer) { clearTimeout(closeConfirm._hideTimer); closeConfirm._hideTimer = null; } if (closeConfirm._teardownTimer) { clearTimeout(closeConfirm._teardownTimer); closeConfirm._teardownTimer = null; } // Laid out first, so it has a width to be centred by and a transition to fade along. closeConfirm.style.display = 'block'; closeConfirm.style.minWidth = '36px'; closeConfirm.style.minHeight = '36px'; closeConfirm.style.visibility = 'visible'; if (!positionCloseOverlay(tabElement, closeConfirm)) { // No box to hang it off - better no button than one in the wrong place. closeConfirm.style.display = 'none'; return; } // Force reflow so opacity transition is visible // eslint-disable-next-line no-unused-expressions closeConfirm.offsetHeight; closeConfirm.classList.add('show'); startCloseOverlayTracking(tabElement, closeConfirm); scheduleAutoHide(); }; // The global swipe fallback reaches this tab's real show/hide through its button, so there is // one implementation of "put the x up" and one of "take it down", not two that drift apart. closeConfirm.__show = showCloseConfirm; closeConfirm.__hide = hideCloseConfirm; const beginReorder = () => { if (swipeDownTriggered) return; // don't start reorder if swipe-to-close was triggered isReordering = true; tabElement.classList.add('dragging'); tabElement.style.opacity = String(DRAG_OPACITY); tabElement.__suppressNextClick = true; lockScroll(true); // Setup container and measurements tabBarElRef = document.querySelector('.tab-bar'); barRect = tabBarElRef.getBoundingClientRect(); const rect = tabElement.getBoundingClientRect(); draggedWidth = rect.width; draggedHeight = rect.height; // Create placeholder occupying original space placeholder = document.createElement('div'); placeholder.className = 'tab tab-placeholder'; placeholder.style.width = `${rect.width}px`; placeholder.style.height = `${rect.height}px`; tabBarElRef.insertBefore(placeholder, tabElement); // Lift dragged tab above flow within tab bar tabElement.style.position = 'absolute'; tabElement.style.zIndex = '10000'; tabElement.style.width = `${rect.width}px`; tabElement.style.top = `${rect.top - barRect.top}px`; dragStartLeft = rect.left - barRect.left; tabElement.style.left = `${dragStartLeft}px`; tabBarElRef.appendChild(tabElement); // Record initial placeholder index among non-dragged children lastPlaceholderIndex = Array.from(tabBarElRef.children).filter(el => el !== tabElement).indexOf(placeholder); }; const endReorder = () => { if (!isReordering) return; if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } const items = Array.from(tabBarElRef.children).filter(el => el !== tabElement); let newIndex = placeholder ? items.indexOf(placeholder) : -1; let reorderedVisible = null; // Hard override: if the dragged tab reaches the far right, always drop into last slot try { const realItems = items.filter(el => el !== placeholder); if (realItems.length) { const lastRect = realItems[realItems.length - 1].getBoundingClientRect(); const dragRect = tabElement.getBoundingClientRect(); const edgeSnap = Math.max(10, Math.min(28, Math.round(draggedWidth * 0.3))); const lastMid = lastRect.left + (lastRect.width / 2); if (dragRect.right >= Math.min(lastRect.right - edgeSnap, lastMid)) { newIndex = realItems.length; // append to last slot } } } catch (_) {} // Build a visible-tabs-only order from the DOM, so hidden tabs don't shift indices. try { const idToTab = new Map(this.tabs.map(t => [String(t.id), t])); const domOrderIds = items.map(el => ( el === placeholder ? String(tab.id) : String(el.dataset.tabId || '') )).filter(Boolean); if (domOrderIds.length) { const ordered = domOrderIds.map(id => idToTab.get(id)).filter(Boolean); if (ordered.length) reorderedVisible = ordered; } } catch (_) {} // Compute target left for settle animation let targetLeft = dragStartLeft; if (placeholder) { const targetRect = placeholder.getBoundingClientRect(); targetLeft = targetRect.left - barRect.left; } // Animate dragged tab to target slot tabElement.style.transition = 'left 180ms ease'; tabElement.style.left = `${targetLeft}px`; const onSettle = () => { tabElement.removeEventListener('transitionend', onSettle); // Update data order let appliedVisibleOrder = false; if (Array.isArray(reorderedVisible) && reorderedVisible.length) { try { const visibleSet = new Set(reorderedVisible.map(t => String(t.id))); const nextTabs = []; let vi = 0; this.tabs.forEach(t => { if (visibleSet.has(String(t.id))) { nextTabs.push(reorderedVisible[vi++] || t); } else { nextTabs.push(t); } }); if (vi === reorderedVisible.length) { this.tabs = nextTabs; appliedVisibleOrder = true; } } catch (_) {} } if (!appliedVisibleOrder) { const oldIndex = this.tabs.findIndex(t => t.id === tab.id); if (newIndex > -1 && oldIndex > -1) { const moved = this.tabs.splice(oldIndex, 1)[0]; this.tabs.splice(newIndex, 0, moved); } } // Cleanup and re-render if (placeholder) { placeholder.remove(); placeholder = null; } tabElement.classList.remove('dragging'); tabElement.style.transition = ''; tabElement.style.position = ''; tabElement.style.zIndex = ''; tabElement.style.width = ''; tabElement.style.top = ''; tabElement.style.left = ''; tabElement.style.opacity = ''; isReordering = false; currentDx = 0; this.renderTabs(); }; // If there's effectively no movement (e.g., only one tab), the transition may not fire. // In that case, settle immediately or via a short fallback timer. const currentLeft = parseFloat(tabElement.style.left) || 0; if (Math.abs(currentLeft - targetLeft) < 0.5) { // No-op movement; settle synchronously tabElement.style.transition = ''; onSettle(); } else { tabElement.addEventListener('transitionend', onSettle, { once: true }); // Fallback in case transitionend is missed setTimeout(() => { try { tabElement.removeEventListener('transitionend', onSettle); } catch (_) {} if (isReordering) onSettle(); }, 240); } // Always release the scroll lock after finishing reorder lockScroll(false); }; const clearHold = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } }; const finalizeGesture = () => { gestureActive = false; activePointerId = null; activeTouchId = null; gestureSource = null; detachGlobalDragHandlers(); }; const onDown = (x, y, source = null) => { startX = x; startY = y; swipeDownTriggered = false; gestureActive = true; gestureSource = source; clearHold(); holdTimer = setTimeout(beginReorder, HOLD_TO_REORDER_MS); attachGlobalDragHandlers(); }; const applyPlaceholderUpdate = (deltaX, force = false, pointerX = null, centerX = null) => { if (!isReordering || !placeholder || !tabBarElRef) return; currentDx = deltaX; const left = dragStartLeft + currentDx; tabElement.style.left = `${left}px`; // Don't thrash placeholder on tiny movements unless forced (drop) if (!force && Math.abs(currentDx) < DEADZONE_PX) return; const visualCenterX = Number.isFinite(centerX) ? centerX : (barRect.left + left + (draggedWidth / 2)); const pointerRefX = Number.isFinite(pointerX) ? pointerX : (Number.isFinite(lastPointerX) ? lastPointerX : null); // Use the dragged tab's visual center to determine slot placement const center = visualCenterX - barRect.left; const items = Array.from(tabBarElRef.children).filter(el => el !== tabElement); let insertBeforeEl = null; const edgeSnap = Math.max(10, Math.min(28, Math.round(draggedWidth * 0.3))); // If a right-edge overlay (tool rail) blocks reach, allow snapping to the last slot let effectiveRightEdge = barRect.right; try { const rail = document.getElementById('toolRail'); if (rail && rail.getBoundingClientRect) { const r = rail.getBoundingClientRect(); if (r.width > 0 && r.left > barRect.left + 4 && r.left < effectiveRightEdge) { effectiveRightEdge = r.left; } } } catch (_) {} const edgeRefX = Number.isFinite(pointerRefX) ? Math.max(pointerRefX, visualCenterX) : visualCenterX; let forceAppend = Number.isFinite(edgeRefX) && edgeRefX >= (effectiveRightEdge - edgeSnap); // Also allow snapping when the dragged tab's right edge clears the last real tab try { const realItems = items.filter(el => el !== placeholder); const lastReal = realItems.length ? realItems[realItems.length - 1] : null; if (lastReal && lastReal.getBoundingClientRect) { const lastRect = lastReal.getBoundingClientRect(); const draggedRight = barRect.left + left + draggedWidth; const lastMid = lastRect.left + (lastRect.width / 2); if (draggedRight >= Math.min(lastRect.right - edgeSnap, lastMid)) { forceAppend = true; } } } catch (_) {} if (!forceAppend) { for (let i = 0; i < items.length; i++) { const r = items[i].getBoundingClientRect(); const mid = (r.left - barRect.left) + (r.width / 2); if (center < mid) { insertBeforeEl = items[i]; break; } } } const currentIndex = items.indexOf(placeholder); const desiredIndex = insertBeforeEl ? items.indexOf(insertBeforeEl) : items.length; if (desiredIndex !== -1 && desiredIndex !== currentIndex) { // FLIP: capture before positions const beforeRects = new Map(); items.forEach(el => { if (el !== placeholder) beforeRects.set(el, el.getBoundingClientRect()); }); // Move placeholder if (insertBeforeEl) { tabBarElRef.insertBefore(placeholder, insertBeforeEl); } else { tabBarElRef.appendChild(placeholder); } // Animate others from before -> after const afterItems = Array.from(tabBarElRef.children).filter(el => el !== tabElement); afterItems.forEach(el => { if (el === placeholder) return; const before = beforeRects.get(el); const after = el.getBoundingClientRect(); if (!before) return; const dx = before.left - after.left; if (Math.abs(dx) < 0.5) return; el.style.transition = 'none'; el.style.transform = `translateX(${dx}px)`; // reflow // eslint-disable-next-line no-unused-expressions el.offsetHeight; el.style.transition = 'transform 160ms ease'; el.style.transform = ''; }); lastPlaceholderIndex = desiredIndex; } }; const onMove = (x, y) => { const dx = x - startX; const dy = y - startY; lastPointerX = x; lastPointerY = y; if (!isReordering) { /* * Detect swipe down to reveal close. * * Skipped while the overlay is already showing: at 20px on this panel a plain * tap drifts far enough to re-trigger, which re-armed the click suppression and * kept re-arming the auto-hide, so the tab could never be tapped into. With the * overlay up, the tap falls through to handleTabSwitch, which dismisses it and * switches. */ if (!swipeDownTriggered && !closeConfirm.classList.contains('show') && dy > SWIPE_DOWN_THRESHOLD && Math.abs(dx) < HORIZONTAL_CANCEL_FOR_SWIPE) { swipeDownTriggered = true; clearHold(); // cancel any active reorder visuals if they happened to start if (isReordering) { isReordering = false; tabElement.classList.remove('dragging'); tabElement.style.transform = ''; tabElement.style.opacity = ''; } showCloseConfirm(); return; } // Mouse: start reorder as soon as the drag is clearly horizontal if (gestureSource === 'mouse' && Math.abs(dx) > DEADZONE_PX && Math.abs(dx) > Math.abs(dy) + 6) { clearHold(); beginReorder(); } else { // If moved significantly horizontally or vertically, keep waiting unless swipe was triggered // If user moves horizontally beyond cancel threshold before reordering, cancel hold if (Math.abs(dx) > HORIZONTAL_CANCEL_FOR_SWIPE || Math.abs(dy) > SWIPE_DOWN_THRESHOLD * 1.5) { clearHold(); } return; } } // Active reorder: move absolute tab and reposition placeholder when crossing midpoints currentDx = dx; if (!rafId) { rafId = requestAnimationFrame(() => { rafId = 0; applyPlaceholderUpdate(currentDx, false, lastPointerX); }); } }; const onUp = (x) => { clearHold(); if (isReordering && !swipeDownTriggered) { if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } const itemsAtDrop = tabBarElRef ? Array.from(tabBarElRef.children).filter(el => el !== tabElement) : []; const placeholderAtEnd = placeholder && itemsAtDrop.length > 0 ? (itemsAtDrop.indexOf(placeholder) === itemsAtDrop.length - 1) : false; if (!placeholderAtEnd) { const finalX = Number.isFinite(x) ? x : lastPointerX; const dx = Number.isFinite(currentDx) ? currentDx : (Number.isFinite(finalX) ? (finalX - startX) : 0); // Force a final placeholder update based on the actual dragged tab position const finalLeft = dragStartLeft + dx; const finalCenterX = barRect.left + finalLeft + (draggedWidth / 2); applyPlaceholderUpdate(dx, true, finalX, finalCenterX); } endReorder(); finalizeGesture(); return; } // If close is visible, start auto-hide timer now if (closeConfirm.classList.contains('show') && !closeConfirm._hideTimer) { scheduleAutoHide(); } // Release scroll lock at gesture end lockScroll(false); finalizeGesture(); }; function onGlobalPointerMove(e) { if (!gestureActive) return; if (activePointerId != null && e.pointerId !== activePointerId) return; onMove(e.clientX, e.clientY); } function onGlobalPointerUp(e) { if (!gestureActive) return; if (activePointerId != null && e.pointerId !== activePointerId) return; onUp(e.clientX); } function onGlobalMouseMove(e) { if (!gestureActive) return; if (activePointerId != null || activeTouchId != null) return; onMove(e.clientX, e.clientY); } function onGlobalMouseUp(e) { if (!gestureActive) return; if (activePointerId != null || activeTouchId != null) return; onUp(e.clientX); } function onGlobalTouchMove(e) { if (!gestureActive || activeTouchId == null) return; const t = findTouchById(e.touches, activeTouchId); if (!t) return; onMove(t.clientX, t.clientY); } function onGlobalTouchEnd(e) { if (!gestureActive || activeTouchId == null) return; const t = findTouchById(e.changedTouches, activeTouchId) || findTouchById(e.touches, activeTouchId); onUp(t ? t.clientX : undefined); } if (isTouchDevice) { // Pointer Events path for touch devices; also allow mouse/pen on hybrid systems tabElement.addEventListener('pointerdown', (e) => { // Avoid being blocked by global capture handlers e.stopPropagation(); activePointerId = e.pointerId; activeTouchId = null; try { tabElement.setPointerCapture(e.pointerId); } catch (_) {} const source = (e.pointerType === 'mouse' || e.pointerType === 'pen') ? 'mouse' : 'touch'; onDown(e.clientX, e.clientY, source); }, { passive: true, capture: true }); tabElement.addEventListener('pointermove', (e) => { if (activePointerId == null || e.pointerId !== activePointerId) return; e.stopPropagation(); onMove(e.clientX, e.clientY); }, { passive: true, capture: true }); tabElement.addEventListener('pointerup', (e) => { if (activePointerId == null || e.pointerId !== activePointerId) return; e.stopPropagation(); try { tabElement.releasePointerCapture(e.pointerId); } catch (_) {} onUp(e.clientX); }, { passive: true, capture: true }); tabElement.addEventListener('touchstart', (e) => { // Ensure swipe gestures on tabs are never blocked in Slides e.stopPropagation(); if (e.touches && e.touches[0]) { activeTouchId = e.touches[0].identifier; activePointerId = null; onDown(e.touches[0].clientX, e.touches[0].clientY, 'touch'); } }, { passive: true, capture: true }); tabElement.addEventListener('touchmove', (e) => { e.stopPropagation(); if (e.touches && e.touches[0]) onMove(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true, capture: true }); tabElement.addEventListener('touchend', (e) => { e.stopPropagation(); const t = (e.changedTouches && e.changedTouches[0]) || null; onUp(t ? t.clientX : undefined); }, { passive: true, capture: true }); tabElement.addEventListener('touchcancel', () => { clearHold(); if (isReordering) endReorder(); finalizeGesture(); }); } else { tabElement.addEventListener('mousedown', (e) => { if (e.button === 0) { activePointerId = null; activeTouchId = null; onDown(e.clientX, e.clientY, 'mouse'); } }); tabElement.addEventListener('mousemove', (e) => { onMove(e.clientX, e.clientY); }); tabElement.addEventListener('mouseup', (e) => { onUp(e.clientX); }); tabElement.addEventListener('mouseleave', () => { clearHold(); }); } tabBar.appendChild(tabElement); }); // Global, robust fallback: capture swipe-down on any tab even if local listeners are bypassed if (!window._tabSwipeFallbackInstalled) { window._tabSwipeFallbackInstalled = true; let _swipeStartX = 0, _swipeStartY = 0; let _swipeTabEl = null; let _swipeTriggered = false; const SWIPE_DOWN_THRESHOLD = 20; const HORIZONTAL_CANCEL_FOR_SWIPE = 35; /* * This used to be a second, slightly different copy of "put the x up" - its own positioning, * its own auto-hide, its own teardown - and it found the button with tabEl.querySelector, * which returned nothing the moment the button had been moved out to . That is the * swipe where no x appears at all. * * Now it only routes: find this tab's button in the portal layer and run the tab's own show. * One implementation, and a tab with no button (the sync tab) simply has nothing to route to. */ const revealCloseForTab = (tabEl) => { const closeConfirm = overlayForTab(tabEl); if (!closeConfirm || typeof closeConfirm.__show !== 'function') return; // Already up: let the tap through to handleTabSwitch, which dismisses and switches. if (closeConfirm.classList.contains('show')) return; closeConfirm.__show(); }; const onStart = (x, y, target) => { const tabEl = target && target.closest ? target.closest('.tab') : null; if (!tabEl) { _swipeTabEl = null; return; } _swipeStartX = x; _swipeStartY = y; _swipeTabEl = tabEl; _swipeTriggered = false; }; const onMove = (x, y) => { if (!_swipeTabEl || _swipeTriggered) return; const dx = x - _swipeStartX; const dy = y - _swipeStartY; if (dy > SWIPE_DOWN_THRESHOLD && Math.abs(dx) < HORIZONTAL_CANCEL_FOR_SWIPE) { _swipeTriggered = true; revealCloseForTab(_swipeTabEl); } }; const onEnd = () => { // auto-hide is handled by per-tab logic after 3s; nothing to do here _swipeTabEl = null; _swipeTriggered = false; }; // Pointer Events path window.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'touch') return; onStart(e.clientX, e.clientY, e.target); }, { capture: true, passive: true }); window.addEventListener('pointermove', (e) => { if (e.pointerType !== 'touch') return; onMove(e.clientX, e.clientY); }, { capture: true, passive: true }); window.addEventListener('pointerup', (e) => { if (e.pointerType !== 'touch') return; onEnd(); }, { capture: true, passive: true }); // Touch Events fallback window.addEventListener('touchstart', (e) => { const t = e.touches && e.touches[0]; if (!t) return; onStart(t.clientX, t.clientY, e.target); }, { capture: true, passive: true }); window.addEventListener('touchmove', (e) => { const t = e.touches && e.touches[0]; if (!t) return; onMove(t.clientX, t.clientY); }, { capture: true, passive: true }); window.addEventListener('touchend', () => { onEnd(); }, { capture: true, passive: true }); window.addEventListener('touchcancel', () => { onEnd(); }, { capture: true, passive: true }); } // The row just changed shape, so "tab 2 of 5" may have too (OSC information line). try { scheduleCompanionActiveTabFeedback(); } catch (_) {} } toggleTabMode(tabId) { const tab = this.tabs.find(t => t.id === tabId); if (!tab) return; // Prevent toggling mode for fixed-mode tabs (whiteboard/notes/alpha/announcements/graphics/prayer/ask) if (tab.mode === 'whiteboard' || tab.mode === 'notes' || tab.mode === 'alpha' || tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'prayer' || tab.mode === 'ask' || tab.ppsync) { console.log('[MODE SWITCH] Fixed-mode tab; ignoring toggle for tab:', tabId); return; } // Save current mode before switching const previousMode = tab.mode; // Toggle the mode if (tab.mode === 'bible') { tab.mode = 'media'; // Initialize media content if not already set if (!tab.mediaContent) { tab.mediaContent = this.getDefaultMediaContent(); tab.mediaTitle = 'Media Tab'; } // Ensure a drawing tool is selected when switching to media mode setTimeout(() => { // Disable word selection if it was active if (selectModeActive) { disableWordSelection(); } // KEEP select tool when switching to media mode - don't force pen tool if (!currentTool) { currentTool = 'pen'; window.currentTool = 'pen'; console.log('[DEBUG] Auto-selecting pen tool for media mode (no tool was set)'); } else if (currentTool === 'select') { // Keep select tool active and ensure cursor works in media mode console.log('[DEBUG] Keeping select tool active when switching to media mode'); selectModeActive = true; window.selectModeActive = true; // Apply cursor immediately when switching to media mode with select tool setTimeout(() => { let selectToolStyle = document.getElementById('select-tool-cursor-override'); if (!selectToolStyle) { selectToolStyle = document.createElement('style'); selectToolStyle.id = 'select-tool-cursor-override'; document.head.appendChild(selectToolStyle); } selectToolStyle.textContent = ` .canvas-media-item, .canvas-media-item * { cursor: move !important; pointer-events: auto !important; } .media-drawing-canvas { pointer-events: none !important; } `; console.log('[MODE SWITCH] Applied select cursor when switching to media mode'); }, 50); } // Only set selectModeActive to false if we're NOT keeping select tool if (currentTool !== 'select') { selectModeActive = false; } const readingArea = document.getElementById(`readingArea${tabId}`); if (readingArea) { readingArea.style.cursor = 'default'; } // Update tool UI to reflect the selected tool if (window.updateToolUI) { window.updateToolUI(); } // Ensure media elements are always clickable updateCanvasPointerEvents(); // Ensure media drawing canvas is properly set up if (window.localMediaHandler && typeof window.localMediaHandler.setupMediaDrawingCanvas === 'function') { window.localMediaHandler.setupMediaDrawingCanvas(tabId); // CRITICAL FIX: Initialize media interactions when toggling to media mode setTimeout(() => { if (window.localMediaHandler.refreshMediaInteractions) { window.localMediaHandler.refreshMediaInteractions(); console.log('[DEBUG] Initialized media interactions on mode toggle'); } }, 150); } }, 100); } else { tab.mode = 'bible'; // Default to pen tool in Bible mode and disable select setTimeout(() => { try { if (typeof disableWordSelection === 'function') disableWordSelection(); } catch (_) {} selectModeActive = false; window.selectModeActive = false; currentTool = 'pen'; window.currentTool = 'pen'; if (typeof updateToolUI === 'function') updateToolUI(); if (typeof updateCanvasPointerEvents === 'function') updateCanvasPointerEvents(); }, 0); } // Re-render tabs to show new icon this.renderTabs(); // Re-render tab content for new mode this.renderTabContent(tab); // NUCLEAR FIX: For media mode, trigger EXACT same sequence as switchTab() if (tab.mode === 'media') { // Initialize LocalMediaHandler if not already done if (!this.localMediaHandler) { console.log('[DEBUG] Creating LocalMediaHandler during mode toggle'); this.localMediaHandler = new LocalMediaHandler(); window.localMediaHandler = this.localMediaHandler; } // SMOOTH: Optimized sequence without flickering delays // Remove annotation canvases smoothly const readingArea = document.getElementById(`readingArea${tab.id}`); if (readingArea) { const annotationCanvases = readingArea.querySelectorAll('.annotation-canvas, .annotation-overlay-canvas'); annotationCanvases.forEach(canvas => { // SMOOTH: Fade out before removal canvas.style.opacity = '0'; canvas.style.transition = 'opacity 0.1s ease'; setTimeout(() => canvas.remove(), 100); console.log('[SMOOTH MODE TOGGLE] Fading out annotation canvas'); }); } // IMMEDIATE: Initialize media tab without delay this.initializeMediaTab(tab.id); console.log('[SMOOTH MODE TOGGLE] Applied smooth transition sequence'); // IMMEDIATE: Apply select tool cursor if it's active (no delay) if (window.currentTool === 'select' && window.selectModeActive) { console.log('[SMOOTH MODE TOGGLE] Select tool is active - applying cursor immediately'); window.fixSelectToolCursor(); } } // If switching back to Bible mode and the tab was previously loaded, restore content if (previousMode === 'media' && tab.mode === 'bible' && tab.loaded && tab.book && tab.chapter && tab.verse) { if (tab.savedText) { this.restoreTabContent(tab); } else { console.log('[DEBUG] Reloading passage after media mode switch for tab:', tab.id); runAutomaticOperation(() => { this.loadPassage(tab.id); }); } } // SMOOTH: Setup annotations for the new mode without delay if (tab.mode === 'bible') { // IMMEDIATE: Setup annotations for smoother transition setupAnnotationForActiveTab(); // Load annotations for the new mode loadAnnotations(tabId); // Ensure header gestures are installed after bible mode render try { this.installHeaderGestures(tab); } catch (_) {} } else if (tab.mode === 'media') { // For media mode, annotation canvases already handled above console.log('[SMOOTH] Media mode annotation cleanup already handled'); } console.log(`[DEBUG] Toggled tab ${tabId} to ${tab.mode} mode`); } // Test function for smooth transitions testSmoothTransitions() { console.log('?? TESTING SMOOTH TRANSITIONS'); console.log('? Optimizations applied:'); console.log(' � Removed 100ms monitoring system for tool switching'); console.log(' � Added CSS transitions for z-index changes (0.1s ease)'); console.log(' � Reduced z-index values to prevent excessive layering'); console.log(' � Batched DOM updates to prevent flickering'); console.log(' � Immediate tool/tab switching without delays'); console.log(' � Smooth fade-out animations for annotation canvas removal'); console.log('?? Test instructions:'); console.log(' 1. Switch from select tool to pen/highlighter - NO flickering'); console.log(' 2. Switch between tabs with media/annotations - NO flashing'); console.log(' 3. Toggle tab modes - smooth transitions'); console.log(' 4. All changes should be instant and polished'); // Make function globally available window.testSmoothTransitions = () => this.testSmoothTransitions(); return 'Smooth transition optimizations active - test tool/tab switching'; } async fetchOrderedAnnouncements(maxItems = 8) { const now = Date.now(); if (this._announcementCache && this._announcementCache.items && this._announcementCache.items.length && (now - (this._announcementCache.ts || 0) < ANNOUNCEMENT_CACHE_TTL_MS)) { return this._announcementCache.items.slice(0, maxItems); } let items = []; try { const listResp = await fetch(`/api/announcements/list?t=${now}`, { cache: 'no-store' }); if (listResp && listResp.ok) { const listData = await listResp.json(); const images = Array.isArray(listData?.images) ? listData.images : []; items = images .map((img, idx) => { if (!img) return null; const order = typeof img.order === 'number' ? img.order : typeof img.position === 'number' ? img.position : typeof img.index === 'number' ? img.index : typeof img.sort === 'number' ? img.sort : typeof img.sortOrder === 'number' ? img.sortOrder : typeof img.orderIndex === 'number' ? img.orderIndex : typeof img.sequence === 'number' ? img.sequence : idx; const version = img.mtimeMs || img.updatedAt || img.version || now; const baseName = img.name || img.file || `announcement-${idx + 1}`; let url = ''; if (img.url) { url = img.url; } else if (baseName) { url = `/Announcements/${encodeURIComponent(baseName)}`; } if (!url) return null; url = url.includes('?') ? `${url}&v=${version}` : `${url}?v=${version}`; const title = img.title || baseName; const alt = img.alt || img.ariaLabel || title || 'Announcement'; return { url, title, alt, order, raw: img }; }) .filter(Boolean) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } } catch (e) { console.warn('[ANNOUNCEMENTS TAB] Failed to fetch ordered announcements list', e); } if (!items.length) { try { const fallback = await this.resolveAnnouncementsImage(); if (fallback && fallback.url) { items = [{ url: fallback.url, title: fallback.title || 'Announcements', alt: fallback.title || 'Announcements', order: 0 }]; } } catch (e) { console.warn('[ANNOUNCEMENTS TAB] Fallback resolve failed', e); } } const normalized = items.filter(Boolean).slice(0, maxItems); this._announcementCache = { ts: now, items: normalized }; announcementsCache = this._announcementCache; // keep global helper in sync return normalized; } async resolveAnnouncementsImage() { const normalizePath = (raw) => { if (!raw) return null; const trimmed = raw.trim(); if (!trimmed) return null; const cleaned = trimmed.replace(/\\/g, '/').replace(/^\/+/, ''); const withPrefix = cleaned.toLowerCase().startsWith('announcements/') ? cleaned : `Announcements/${cleaned}`; return withPrefix; }; const makeUrlVariants = (raw) => { const normalized = normalizePath(raw); if (!normalized) return []; const cacheBust = `${normalized}${normalized.includes('?') ? '&' : '?'}v=${Date.now()}`; const title = decodeURIComponent((normalized.split('/').pop() || 'Announcements').split('?')[0]); const rel = cacheBust; // Also provide file:// variant pointing to app base (assumes dist root execution) // The file:// variant needs Node's process.cwd(), which is unavailable in this // contextIsolated renderer — guard it so it can't throw and kill the whole fallback. const variants = [ { url: rel, title, attempt: rel }, { url: `/${rel}`, title, attempt: `/${rel}` } ]; if (typeof process !== 'undefined' && process && typeof process.cwd === 'function') { const fileAbs = `file://${process.cwd()}/${normalized}`.replace(/\\/g, '/'); variants.push({ url: fileAbs, title, attempt: fileAbs }); } return variants; }; const candidates = []; try { const cfg = await fetch('/Announcements/default.txt', { cache: 'no-store' }); if (cfg.ok) { const name = (await cfg.text()).trim(); if (name) candidates.push(name); } } catch (e) { console.warn('[ANNOUNCEMENTS TAB] Failed to read Announcements/default.txt', e); } candidates.push( 'Announcements/Announcements.png', 'Announcements/Announcements.jpg', 'Announcements/announcement.jpg', 'Announcements/splash.jpg', 'announcement.jpg', 'Announcements/announcement.png', 'Announcements/splash.png', 'announcement.png', 'Announcements.png' ); const attempts = []; const urls = []; candidates.forEach(cand => { const variants = makeUrlVariants(cand); variants.forEach(v => { attempts.push(v.attempt); urls.push(v); }); }); if (urls.length) { console.log('[ANNOUNCEMENTS TAB] Candidate images:', attempts); return { url: urls[0].url, title: urls[0].title, attempts, all: urls }; } console.warn('[ANNOUNCEMENTS TAB] No announcement image candidates resolved.', attempts); return { url: '', title: 'Announcements', attempts, all: [] }; } async fetchOrderedGraphics(maxItems = 16) { const now = Date.now(); if (this._graphicsCache && this._graphicsCache.items && this._graphicsCache.items.length && (now - (this._graphicsCache.ts || 0) < GRAPHICS_CACHE_TTL_MS)) { return this._graphicsCache.items.slice(0, maxItems); } let items = []; try { const listResp = await fetch(`/api/graphics/list?t=${now}`, { cache: 'no-store' }); if (listResp && listResp.ok) { const listData = await listResp.json(); const images = Array.isArray(listData?.images) ? listData.images : (Array.isArray(listData?.files) ? listData.files : []); items = images .map((img, idx) => { if (!img) return null; const order = typeof img.order === 'number' ? img.order : typeof img.position === 'number' ? img.position : typeof img.index === 'number' ? img.index : typeof img.sort === 'number' ? img.sort : typeof img.sortOrder === 'number' ? img.sortOrder : typeof img.orderIndex === 'number' ? img.orderIndex : typeof img.sequence === 'number' ? img.sequence : idx; const version = img.mtimeMs || img.updatedAt || img.version || now; const baseName = img.name || img.file || `graphic-${idx + 1}`; let url = ''; if (img.url) { url = img.url; } else if (baseName) { url = `/Graphics/${encodeURIComponent(baseName)}`; } if (!url) return null; url = url.includes('?') ? `${url}&v=${version}` : `${url}?v=${version}`; const title = img.title || baseName; const alt = img.alt || img.ariaLabel || title || 'Graphic'; return { url, title, alt, order, raw: img }; }) .filter(Boolean) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } } catch (e) { console.warn('[GRAPHICS TAB] Failed to fetch ordered graphics list', e); } if (!items.length) { try { const fallback = await this.resolveGraphicsImage(); if (fallback && fallback.url) { items = [{ url: fallback.url, title: fallback.title || 'Graphics', alt: fallback.title || 'Graphics', order: 0 }]; } } catch (e) { console.warn('[GRAPHICS TAB] Fallback resolve failed', e); } } const normalized = items.filter(Boolean).slice(0, maxItems); this._graphicsCache = { ts: now, items: normalized }; graphicsCache = this._graphicsCache; // keep global helper in sync return normalized; } // Unified media fetch by category. 'graphics' delegates to the existing (unchanged) path; // 'maps' fetches the parallel Maps & Timelines list from /api/maps/list and /Maps/. async fetchOrderedMedia(category = 'graphics', maxItems = 16) { if (category !== 'maps') return this.fetchOrderedGraphics(maxItems); const now = Date.now(); if (this._mapsCache && this._mapsCache.items && this._mapsCache.items.length && (now - (this._mapsCache.ts || 0) < GRAPHICS_CACHE_TTL_MS)) { return this._mapsCache.items.slice(0, maxItems); } let items = []; try { const listResp = await fetch(`/api/maps/list?t=${now}`, { cache: 'no-store' }); if (listResp && listResp.ok) { const listData = await listResp.json(); const images = Array.isArray(listData?.images) ? listData.images : (Array.isArray(listData?.files) ? listData.files : []); items = images .map((img, idx) => { if (!img) return null; const order = typeof img.order === 'number' ? img.order : idx; const version = img.mtimeMs || img.version || now; const baseName = img.name || img.file || `map-${idx + 1}`; let url = img.url ? img.url : (baseName ? `/Maps/${encodeURIComponent(baseName)}` : ''); if (!url) return null; url = url.includes('?') ? `${url}&v=${version}` : `${url}?v=${version}`; const title = img.title || baseName; return { url, title, alt: img.alt || title || 'Map', order, raw: img }; }) .filter(Boolean) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } } catch (e) { console.warn('[MAPS TAB] Failed to fetch ordered maps list', e); } const normalized = items.filter(Boolean).slice(0, maxItems); this._mapsCache = { ts: now, items: normalized }; return normalized; } async resolveGraphicsImage() { const normalizePath = (raw) => { if (!raw) return null; const trimmed = raw.trim(); if (!trimmed) return null; const cleaned = trimmed.replace(/\\/g, '/').replace(/^\/+/, ''); const withPrefix = cleaned.toLowerCase().startsWith('graphics/') ? cleaned : `Graphics/${cleaned}`; return withPrefix; }; const makeUrlVariants = (raw) => { const normalized = normalizePath(raw); if (!normalized) return []; const cacheBust = `${normalized}${normalized.includes('?') ? '&' : '?'}v=${Date.now()}`; const title = decodeURIComponent((normalized.split('/').pop() || 'Graphics').split('?')[0]); const rel = cacheBust; return [ { url: rel, title, attempt: rel }, { url: `/${rel}`, title, attempt: `/${rel}` } ]; }; const candidates = []; try { const cfg = await fetch('/Graphics/default.txt', { cache: 'no-store' }); if (cfg.ok) { const name = (await cfg.text()).trim(); if (name) candidates.push(name); } } catch (e) { console.warn('[GRAPHICS TAB] Failed to read Graphics/default.txt', e); } candidates.push( 'Graphics/Graphics.png', 'Graphics/Graphics.jpg', 'Graphics/graphic.png', 'Graphics/graphic.jpg', 'Graphics/splash.png', 'Graphics/splash.jpg' ); const attempts = []; const urls = []; candidates.forEach(cand => { const variants = makeUrlVariants(cand); variants.forEach(v => { attempts.push(v.attempt); urls.push(v); }); }); if (urls.length) { console.log('[GRAPHICS TAB] Candidate images:', attempts); return { url: urls[0].url, title: urls[0].title, attempts, all: urls }; } console.warn('[GRAPHICS TAB] No graphics image candidates resolved.', attempts); return { url: '', title: 'Graphics', attempts, all: [] }; } getDefaultMediaContent() { // Return empty content - LocalMediaHandler will populate the proper content // This prevents conflicts between default content and LocalMediaHandler content return `
Setting up media workspace...
`; } generateMediaThumbnails(driveFolderId) { if (!driveFolderId) { return '
No media source configured
'; } // Start the actual loading process setTimeout(() => { this.initializeAndLoadDriveFiles(driveFolderId); }, 100); // Return loading placeholder initially return `
?
Loading files...
Connecting to Google Drive
`; } async initializeAndLoadDriveFiles(driveFolderId) { try { console.log('[DEBUG] Starting Google Drive initialization...'); const initialized = await this.initializeGoogleDriveAPI(); if (initialized) { console.log('[DEBUG] API initialized, starting authentication...'); await this.authenticateAndLoadFiles(driveFolderId); } else { console.error('[ERROR] Failed to initialize Google Drive API'); this.showDriveAuthError(); } } catch (error) { console.error('[ERROR] Failed to initialize and load Drive files:', error); this.showDriveError(error); } } async initializeGoogleDriveAPI() { console.log('[DEBUG] Starting Google Drive API initialization...'); if (!window.google || !window.gapi) { console.log('[DEBUG] Loading Google APIs script...'); await this.loadGoogleAPIs(); } try { console.log('[DEBUG] Initializing gapi client...'); // Initialize basic API client await new Promise((resolve) => { gapi.load('client', resolve); }); await gapi.client.init({ apiKey: 'AIzaSyCxaL3Ki_XpLiEAvsWG7QtlUF9w4ZGlZ9k' }); // Initialize token client const tokenClient = google.accounts.oauth2.initTokenClient({ client_id: '208409322947-960nuv1adq19f9joop57g76ml84hm0i2.apps.googleusercontent.com', scope: 'https://www.googleapis.com/auth/drive.readonly', callback: () => {} // Will be overridden in authenticateAndLoadFiles }); // Store token client for later use this.tokenClient = tokenClient; console.log('[DEBUG] Google Drive API initialized successfully'); console.log('[DEBUG] Current origin:', window.location.origin); return true; } catch (error) { console.error('[ERROR] Failed to initialize Google Drive API:', error); console.error('[ERROR] Error details:', { message: error.message, details: error.details || 'No additional details', origin: window.location.origin }); // Show user-friendly error based on error type if (error.message && error.message.includes('origin')) { console.error('[ERROR] This appears to be a CORS/origin configuration issue.'); console.error('[ERROR] Make sure your current origin (' + window.location.origin + ') is added to the authorized JavaScript origins in Google Cloud Console.'); } return false; } } async loadGoogleAPIs() { await Promise.all([ // Load Google Identity Services new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://accounts.google.com/gsi/client'; script.async = true; script.defer = true; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }), // Load basic Google API client new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://apis.google.com/js/api.js'; script.async = true; script.defer = true; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }) ]); } async authenticateAndLoadFiles(driveFolderId) { try { console.log('[DEBUG] User not signed in, prompting for authentication...'); // Request access token await new Promise((resolve, reject) => { this.tokenClient.callback = async (response) => { if (response.error !== undefined) { reject(response); return; } console.log('[DEBUG] User authenticated, fetching files...'); try { await this.fetchDriveFiles(driveFolderId); resolve(); } catch (error) { reject(error); } }; this.tokenClient.requestAccessToken(); }); } catch (error) { console.error('[ERROR] Authentication failed:', error); this.showDriveAuthError(); } } async fetchDriveFiles(folderId) { try { console.log('[DEBUG] Attempting to fetch files from folder:', folderId); // Make direct API request const response = await fetch( `https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed=false&fields=files(id,name,mimeType,thumbnailLink,webViewLink,size,modifiedTime)&pageSize=50&supportsAllDrives=true&includeItemsFromAllDrives=true&key=${encodeURIComponent('AIzaSyCxaL3Ki_XpLiEAvsWG7QtlUF9w4ZGlZ9k')}`, { headers: { 'Authorization': `Bearer ${gapi.client.getToken().access_token}` } } ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); console.log('[DEBUG] API response:', result); const files = result.files || []; console.log('[DEBUG] Fetched files:', files); if (files.length > 0) { this.displayRealMediaThumbnails(files); } else { this.showEmptyFolderMessage(); } } catch (error) { console.error('[ERROR] Failed to fetch files:', error); if (error.status === 403) { console.error('[ERROR] Permission denied. Please check folder permissions.'); } this.showDriveError(error); } } displayRealMediaThumbnails(files) { const mediaGrid = document.querySelector('.media-card-grid'); if (!mediaGrid) return; // Keep the link chooser card and replace the rest const linkChooserCard = mediaGrid.querySelector('.link-chooser-card'); // Freeze toolbar/tab width AND prevent horizontal scroll reflow while slides load try { const toolbar = document.querySelector('.toolbar'); const tabBar = document.querySelector('.tab-bar'); const appRoot = document.getElementById('app'); if (toolbar && !toolbar._lockedWidth) { toolbar._lockedWidth = toolbar.offsetWidth; toolbar.style.width = toolbar._lockedWidth + 'px'; } if (tabBar && !tabBar._lockedWidth) { tabBar._lockedWidth = tabBar.offsetWidth; tabBar.style.width = tabBar._lockedWidth + 'px'; } if (appRoot && !appRoot._lockedPadding) { // Lock left/right padding so safe-area calc doesn't cause a brief reflow const cs = getComputedStyle(appRoot); appRoot._lockedPadding = { left: cs.paddingLeft, right: cs.paddingRight }; appRoot.style.paddingLeft = cs.paddingLeft; appRoot.style.paddingRight = cs.paddingRight; } } catch (_) {} mediaGrid.innerHTML = ''; if (linkChooserCard) { mediaGrid.appendChild(linkChooserCard); } files.forEach(file => { const fileType = this.getFileType(file.mimeType); const thumbnailUrl = file.thumbnailLink || this.getDefaultThumbnail(fileType); const mediaCard = document.createElement('div'); mediaCard.className = 'media-card'; mediaCard.dataset.mediaId = file.id; mediaCard.dataset.mediaType = fileType; mediaCard.draggable = true; // Escape the externally-controlled Drive file name / thumbnail URL to prevent DOM XSS. const safeName = escapeAskHtml(file.name); const safeThumb = escapeAskHtml(thumbnailUrl); mediaCard.innerHTML = `
${thumbnailUrl ? `${safeName}` : `
${this.getDefaultThumbnail(fileType)}
` }
${escapeAskHtml(this.truncateFileName(file.name))}
${fileType} � ${this.formatFileSize(file.size)}
`; mediaGrid.appendChild(mediaCard); }); // Re-setup drag and drop for new cards and release toolbar/tab locks setTimeout(() => { this.setupMediaTabInteractions(this.activeTabId); try { const toolbar = document.querySelector('.toolbar'); const tabBar = document.querySelector('.tab-bar'); const appRoot = document.getElementById('app'); if (toolbar && toolbar._lockedWidth) { toolbar.style.width = ''; toolbar._lockedWidth = null; } if (tabBar && tabBar._lockedWidth) { tabBar.style.width = ''; tabBar._lockedWidth = null; } if (appRoot && appRoot._lockedPadding) { appRoot.style.paddingLeft = ''; appRoot.style.paddingRight = ''; appRoot._lockedPadding = null; } } catch (_) {} }, 100); } getFileType(mimeType) { if (window.localMediaHandler) { return window.localMediaHandler.getFileType(mimeType); } return 'file'; } getDefaultThumbnail(fileType) { if (window.localMediaHandler) { return window.localMediaHandler.getFileIcon(fileType); } return '??'; } truncateFileName(name, maxLength = 15) { if (window.localMediaHandler) { return window.localMediaHandler.truncateFileName(name, maxLength); } return name; } formatFileSize(bytes) { if (window.localMediaHandler) { return window.localMediaHandler.formatFileSize(bytes); } return ''; } showDriveAuthError() { const mediaGrid = document.querySelector('.media-card-grid'); if (!mediaGrid) return; const linkChooserCard = mediaGrid.querySelector('.link-chooser-card'); mediaGrid.innerHTML = ''; if (linkChooserCard) mediaGrid.appendChild(linkChooserCard); const errorCard = document.createElement('div'); errorCard.className = 'media-card error-card'; errorCard.innerHTML = `
??
Authentication Required
Click to sign in to Google Drive
`; errorCard.addEventListener('click', () => { const driveFolderId = localStorage.getItem('driveFolderId'); if (driveFolderId) { this.authenticateAndLoadFiles(driveFolderId); } }); mediaGrid.appendChild(errorCard); } showDriveError(error) { const mediaGrid = document.querySelector('.media-card-grid'); if (!mediaGrid) return; const linkChooserCard = mediaGrid.querySelector('.link-chooser-card'); mediaGrid.innerHTML = ''; if (linkChooserCard) mediaGrid.appendChild(linkChooserCard); const errorCard = document.createElement('div'); errorCard.className = 'media-card error-card'; errorCard.innerHTML = `
?
Error Loading Files
Check folder permissions
`; mediaGrid.appendChild(errorCard); } showEmptyFolderMessage() { const mediaGrid = document.querySelector('.media-card-grid'); if (!mediaGrid) return; const linkChooserCard = mediaGrid.querySelector('.link-chooser-card'); mediaGrid.innerHTML = ''; if (linkChooserCard) mediaGrid.appendChild(linkChooserCard); const emptyCard = document.createElement('div'); emptyCard.className = 'media-card empty-card'; emptyCard.innerHTML = `
??
Empty Folder
No files found in this folder
`; mediaGrid.appendChild(emptyCard); } setDriveFolderId(folderIdOrUrl) { // Clean up the folder ID from a full URL if needed let cleanId = folderIdOrUrl.trim(); // Handle full Google Drive URLs const folderMatch = cleanId.match(/\/folders\/([^/?&#]+)/); if (folderMatch) { cleanId = folderMatch[1]; } // Handle shared drive URLs (if they contain different patterns) const driveMatch = cleanId.match(/drive\.google\.com.*[\/=]([a-zA-Z0-9_-]{25,})/); if (driveMatch && !folderMatch) { cleanId = driveMatch[1]; } // Remove any query parameters or fragments if it's just an ID cleanId = cleanId.split('?')[0].split('#')[0]; console.log('[DEBUG] Setting Drive folder ID:', cleanId); // Save the folder ID localStorage.setItem('driveFolderId', cleanId); // Refresh the current tab's content if it's in media mode const currentTab = this.tabs.find(t => t.id === this.activeTabId); if (currentTab && currentTab.mode === 'media') { currentTab.mediaContent = this.getDefaultMediaContent(); this.renderTabContent(currentTab); // Initialize Google Drive API and load files setTimeout(async () => { const apiInitialized = await this.initializeGoogleDriveAPI(); if (apiInitialized) { await this.authenticateAndLoadFiles(cleanId); } }, 500); } } setupMediaTabInteractions(tabId) { // Wait for DOM to be ready before setting up media handler setTimeout(() => { console.log('[DEBUG] Setting up media tab interactions for tab:', tabId); // Initialize local media handler if not already initialized if (!this.localMediaHandler) { console.log('[DEBUG] Creating new LocalMediaHandler'); this.localMediaHandler = new LocalMediaHandler(); // Make it globally available window.localMediaHandler = this.localMediaHandler; } // Ensure the media content div exists and is ready const mediaContentDiv = document.getElementById(`mediaContent${tabId}`); if (!mediaContentDiv) { console.error('[ERROR] Media content div not found for tab:', tabId); return; } // Setup the media view now that the content div exists console.log('[DEBUG] Setting up media view for tab:', tabId); this.localMediaHandler.setupMediaView(); // Verify elements were created and retry if necessary const verifyAndSetup = (attempt = 1) => { setTimeout(() => { const plusButton = document.getElementById('refreshMediaBtn'); const fileInput = document.getElementById('fileInput'); console.log('[DEBUG] Verification attempt', attempt, '- Plus button found:', !!plusButton); console.log('[DEBUG] Verification attempt', attempt, '- File input found:', !!fileInput); if (!plusButton || !fileInput) { if (attempt < 3) { console.warn('[WARN] Media elements not found, retrying setup... (attempt', attempt, 'of 3)'); this.localMediaHandler.setupMediaView(); verifyAndSetup(attempt + 1); } else { console.error('[ERROR] Failed to setup media elements after 3 attempts'); } } else { console.log('[DEBUG] Media elements verified successfully on attempt', attempt); // Ensure media elements have proper pointer events plusButton.style.pointerEvents = 'auto'; fileInput.style.pointerEvents = 'auto'; // CRITICAL: Force the media button to be clickable plusButton.style.cursor = 'pointer'; plusButton.style.zIndex = '10000'; plusButton.style.position = 'relative'; // Ensure the add icon is also clickable const addIcon = plusButton.querySelector('.add-icon'); if (addIcon) { addIcon.style.pointerEvents = 'auto'; addIcon.style.cursor = 'pointer'; } // Add a manual click test function for debugging window.testMediaButton = () => { console.log('[TEST] Manually testing media button click'); plusButton.click(); }; // Add a function to force open file picker window.forceOpenFilePicker = () => { console.log('[FORCE] Forcing file picker to open...'); // Prevent multiple calls if (window._filePickerOpen) { console.log('[FORCE] File picker already open, ignoring call'); return; } window._filePickerOpen = true; fileInput.click(); // Reset flag after a delay setTimeout(() => { window._filePickerOpen = false; }, 1000); }; console.log('[DEBUG] Plus button setup verified successfully'); console.log('[DEBUG] Test functions available: window.testMediaButton() and window.forceOpenFilePicker()'); // Set up a periodic check to ensure the button stays clickable const ensureMediaButtonClickable = () => { if (plusButton) { plusButton.style.pointerEvents = 'auto'; plusButton.style.cursor = 'pointer'; plusButton.style.zIndex = '10000'; if (addIcon) { addIcon.style.pointerEvents = 'auto'; addIcon.style.cursor = 'pointer'; } } }; // Run the check every 2 seconds (deduped) if (this._mediaPlusIntervalId) { try { clearInterval(this._mediaPlusIntervalId); } catch (_) {} } this._mediaPlusIntervalId = setInterval(ensureMediaButtonClickable, 2000); } }, 100 * attempt); }; verifyAndSetup(); // Save media content when changes are made const saveMediaContent = () => { const tab = this.tabs.find(t => t.id === tabId); if (tab) { const mediaContentDiv = document.getElementById(`mediaContent${tabId}`); if (mediaContentDiv) { tab.mediaContent = mediaContentDiv.innerHTML; } // Also save media thumbnails if they exist const mediaScrollContainer = document.querySelector('.media-scroll-container'); if (mediaScrollContainer && !tab.mediaThumbnails) { tab.mediaThumbnails = []; const mediaCards = mediaScrollContainer.querySelectorAll('.media-card:not(.add-files)'); mediaCards.forEach(card => { const thumbnailData = { id: Date.now() + Math.random(), path: card.dataset.mediaPath, name: card.title, type: card.dataset.mediaType, icon: card.querySelector('.file-icon')?.textContent || '??' }; tab.mediaThumbnails.push(thumbnailData); }); console.log('[DEBUG] Saved media thumbnails to tab:', tab.mediaThumbnails.length, 'thumbnails'); } } }; // Setup auto-save after elements are ready setTimeout(() => { const mediaCanvas = document.getElementById('mediaCanvas'); if (mediaCanvas) { // Disconnect any previous observer to avoid leaks if (this._mediaCanvasObserver && this._mediaCanvasObserver.disconnect) { try { this._mediaCanvasObserver.disconnect(); } catch (_) {} } const observer = new MutationObserver(saveMediaContent); observer.observe(mediaCanvas, { childList: true, subtree: true, attributes: true }); this._mediaCanvasObserver = observer; console.log('[DEBUG] Media canvas auto-save observer attached'); } // CRITICAL FIX: Initialize media interactions when tab is first set up if (this.localMediaHandler && this.localMediaHandler.refreshMediaInteractions) { this.localMediaHandler.refreshMediaInteractions(); console.log('[DEBUG] Initialized media interactions on new media tab setup'); } }, 200); }, 50); } showLinkChooserPopup() { const driveFolderId = localStorage.getItem('driveFolderId') || ''; const popup = document.createElement('div'); popup.className = 'link-chooser-popup'; popup.innerHTML = ` `; document.body.appendChild(popup); const input = popup.querySelector('#popupDriveFolderInput'); const statusElement = popup.querySelector('#inputStatus'); const saveBtn = popup.querySelector('#saveBtn'); // Real-time validation and feedback const updateInputStatus = () => { const value = input.value.trim(); const statusIcon = statusElement.querySelector('.status-icon'); const statusText = statusElement.querySelector('.status-text'); if (!value) { statusIcon.textContent = '??'; statusText.textContent = 'Ready to paste your link'; statusElement.className = 'input-status'; saveBtn.textContent = 'Save & Refresh Media'; } else if (value.includes('drive.google.com/drive/folders/')) { statusIcon.textContent = '?'; statusText.textContent = 'Valid Google Drive folder link detected'; statusElement.className = 'input-status valid'; saveBtn.textContent = 'Save & Load Media Thumbnails'; } else if (value.length > 20 && /^[a-zA-Z0-9_-]+$/.test(value)) { statusIcon.textContent = '?'; statusText.textContent = 'Valid folder ID format'; statusElement.className = 'input-status valid'; saveBtn.textContent = 'Save & Load Media Thumbnails'; } else { statusIcon.textContent = '??'; statusText.textContent = 'Please enter a valid Google Drive link or folder ID'; statusElement.className = 'input-status warning'; saveBtn.textContent = 'Save Anyway'; } }; input.addEventListener('input', updateInputStatus); input.addEventListener('paste', () => { setTimeout(updateInputStatus, 10); }); // Initial status update updateInputStatus(); // Event listeners for popup const closePopup = () => { document.body.removeChild(popup); }; popup.querySelector('.popup-close').addEventListener('click', closePopup); popup.querySelector('.popup-overlay').addEventListener('click', closePopup); popup.querySelector('.cancel').addEventListener('click', closePopup); saveBtn.addEventListener('click', () => { const newValue = input.value.trim(); if (newValue) { this.setDriveFolderId(newValue); // Show a brief success message statusElement.querySelector('.status-icon').textContent = '??'; statusElement.querySelector('.status-text').textContent = 'Saved! Refreshing media...'; statusElement.className = 'input-status success'; setTimeout(closePopup, 1000); } else { closePopup(); } }); // Focus on input and select all text if there's existing content setTimeout(() => { input.focus(); if (input.value) { input.select(); } }, 100); } addMediaToCanvas(mediaData, x, y, canvas) { if (this.localMediaHandler) { this.localMediaHandler.addMediaToCanvas(mediaData, x, y, canvas); } } makeMediaItemInteractive(item) { if (this.localMediaHandler) { this.localMediaHandler.makeMediaItemInteractive(item); } } // Install swipe-to-navigate gestures on the header reference (tap-to-top replaced by explicit button) installHeaderGestures(tab) { try { if (!tab || !tab.id) return; // Ensure no lingering global blockers can suppress header gestures try { this.releaseGlobalTouchBlocks(); } catch (_) {} const header = document.getElementById(`chapterHeader${tab.id}`); if (!header) return; // Bind gestures on the stable header element so reflows don't detach listeners const refEl = header; if (!refEl) return; const alreadyInstalled = !!header._headerGesturesInstalled; // Keep header interactive without restricting vertical scroll try { refEl.style.removeProperty('touch-action'); refEl.style.removeProperty('overscroll-behavior'); refEl.style.setProperty('pointer-events', 'auto', 'important'); } catch (_) {} const usePointer = typeof window !== 'undefined' && 'PointerEvent' in window; // Only install the header-level listeners once per header element if (!alreadyInstalled) { // Swipe: navigate to previous/next chapter let startX = 0, startY = 0, tracking = false, pointerId = null; const SWIPE_X = 24; // px threshold (more forgiving) const SWIPE_Y_MAX = 48; // vertical allowance (more tolerant) const getXY = (e) => { if (e.touches && e.touches[0]) return { x: e.touches[0].clientX, y: e.touches[0].clientY }; if (e.changedTouches && e.changedTouches[0]) return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY }; return { x: e.clientX || 0, y: e.clientY || 0 }; }; const onDown = (e) => { const p = getXY(e); startX = p.x; startY = p.y; tracking = true; pointerId = e.pointerId || null; // Reset stale movement so a prior swipe doesn't suppress the next tap header._lastTapMove = 0; // Keep watchdog alive when user touches the header (extra safety) try { if (this.startHeaderGestureWatch) this.startHeaderGestureWatch(tab.id); } catch (_) {} }; const onMove = (e) => { if (!tracking) return; const p = getXY(e); const dx = p.x - startX; const dy = p.y - startY; // Record movement magnitude to disambiguate tap header._lastTapMove = Math.max(Math.abs(dx), Math.abs(dy)); }; const onUp = (e) => { if (!tracking) return; tracking = false; pointerId = null; const p = getXY(e); const dx = p.x - startX; const dy = p.y - startY; header._lastTapMove = Math.max(Math.abs(dx), Math.abs(dy)); if (Math.abs(dy) <= SWIPE_Y_MAX && Math.abs(dx) >= SWIPE_X) { // Mark swipe handled so tap won't also run header._lastSwipeTime = Date.now(); // Clear any saved tap timestamp to prioritize swipe header._lastTapTs = 0; // No global preventDefault to avoid interfering with tools if (dx < 0) { // Swipe left -> next chapter this.navigateAdjacentChapter(tab.id, +1); } else { // Swipe right -> previous chapter this.navigateAdjacentChapter(tab.id, -1); } } }; // Use either pointer events or touch events (avoid double-binding) if (usePointer) { refEl.addEventListener('pointerdown', onDown, { passive: true }); refEl.addEventListener('pointermove', onMove, { passive: true }); refEl.addEventListener('pointerup', onUp, { passive: true }); refEl.addEventListener('pointercancel', onUp, { passive: true }); } else { refEl.addEventListener('touchstart', onDown, { passive: true }); refEl.addEventListener('touchmove', onMove, { passive: true }); refEl.addEventListener('touchend', onUp, { passive: true }); refEl.addEventListener('touchcancel', onUp, { passive: true }); } } // Tap-to-top behavior is now provided by an explicit button in the header header._headerGesturesInstalled = true; } catch (_) {} } // Robustly clear any global touch/scroll blockers that could interfere with gestures releaseGlobalTouchBlocks() { try { // Release tab drag/reorder scroll locks (if stuck) if (window._tabScrollMoveBlocker) { ['touchmove','wheel','mousewheel','DOMMouseScroll'].forEach(type => { try { window.removeEventListener(type, window._tabScrollMoveBlocker, { capture: true }); } catch (_) {} try { document.removeEventListener(type, window._tabScrollMoveBlocker, { capture: true }); } catch (_) {} }); window._tabScrollMoveBlocker = null; } if (typeof window._tabScrollLockCount === 'number') { window._tabScrollLockCount = 0; } } catch (_) {} try { // Release Slides/Media global gesture blockers (if any remain) if (window._mediaTouchMoveBlocker) { ['touchstart','touchmove','touchend','gesturestart','gesturechange'].forEach(type => { try { window.removeEventListener(type, window._mediaTouchMoveBlocker, { capture: true }); } catch (_) {} try { document.removeEventListener(type, window._mediaTouchMoveBlocker, { capture: true }); } catch (_) {} try { document.documentElement.removeEventListener(type, window._mediaTouchMoveBlocker, { capture: true }); } catch (_) {} }); window._mediaTouchMoveBlocker = null; } if (window._mediaWheelBlocker) { try { window.removeEventListener('wheel', window._mediaWheelBlocker, { capture: true }); } catch (_) {} try { document.removeEventListener('wheel', window._mediaWheelBlocker, { capture: true }); } catch (_) {} try { document.documentElement.removeEventListener('wheel', window._mediaWheelBlocker, { capture: true }); } catch (_) {} try { document.body.removeEventListener('wheel', window._mediaWheelBlocker, { capture: true }); } catch (_) {} try { window.removeEventListener('mousewheel', window._mediaWheelBlocker, { capture: true }); } catch (_) {} try { document.removeEventListener('DOMMouseScroll', window._mediaWheelBlocker, { capture: true }); } catch (_) {} window._mediaWheelBlocker = null; } // Release legacy pointer blockers added in media mode if (window._mediaLegacyPointerBlockers && window._mediaLegacyPointerBlockers.length) { try { window._mediaLegacyPointerBlockers.forEach(({ type, handler }) => { try { window.removeEventListener(type, handler, { capture: true }); } catch (_) {} try { document.removeEventListener(type, handler, { capture: true }); } catch (_) {} try { document.documentElement.removeEventListener(type, handler, { capture: true }); } catch (_) {} try { document.body.removeEventListener(type, handler, { capture: true }); } catch (_) {} }); } catch (_) {} window._mediaLegacyPointerBlockers = []; } // Remove any strong CSS locks left by media mode try { const style = document.getElementById('media-scroll-lock-style'); if (style) style.remove(); } catch (_) {} // Normalize root/body touch/scroll styles in case they were left clamped try { document.documentElement.style.overflow = ''; document.documentElement.style.overscrollBehavior = ''; document.documentElement.style.touchAction = ''; document.body.style.overflow = ''; document.body.style.overscrollBehavior = ''; document.body.style.touchAction = ''; } catch (_) {} } catch (_) {} } // Ensure active Bible tab can receive taps, swipes, and drawing input ensureBibleInputReady(tabId) { try { const tab = this.tabs.find(t => t.id === tabId); if (!tab || (tab.mode !== 'bible' && tab.mode !== 'alpha')) return; // Release any stray global blockers first try { this.releaseGlobalTouchBlocks(); } catch (_) {} // Ensure header gestures are installed try { this.installHeaderGestures(tab); } catch (_) {} // Ensure reading area is interactive const ra = document.getElementById(`readingArea${tab.id}`); if (ra) { try { ra.style.removeProperty('touch-action'); ra.style.removeProperty('overscroll-behavior'); ra.style.setProperty('pointer-events', 'auto', 'important'); } catch (_) {} } // Ensure annotation canvases exist and pointer-events are correct for current tool try { if (typeof setupAnnotationForActiveTab === 'function') setupAnnotationForActiveTab(); } catch (_) {} try { if (typeof updateCanvasPointerEvents === 'function') updateCanvasPointerEvents(); } catch (_) {} } catch (_) {} } // Watchdog to keep header gestures and global touch state healthy over time startHeaderGestureWatch(tabId) { try { // Stop existing watcher if (this._headerGestureWatchId) { clearInterval(this._headerGestureWatchId); this._headerGestureWatchId = null; } } catch (_) {} try { const runCheck = () => { try { const activeId = this.activeTabId; const tab = this.tabs.find(t => t.id === activeId); if (!tab || tab.id !== tabId || (tab.mode !== 'bible' && tab.mode !== 'alpha')) return; // only when active bible/alpha tab const header = document.getElementById(`chapterHeader${tabId}`); if (!header) return; // Re-install gestures if needed if (!header._headerGesturesInstalled) { this.installHeaderGestures(tab); } // Do not enforce restrictive gesture CSS on header try { header.style.removeProperty('touch-action'); header.style.removeProperty('overscroll-behavior'); } catch (_) {} // Release any stray global blockers this.releaseGlobalTouchBlocks(); } catch (_) {} }; // Initial checks setTimeout(runCheck, 0); setTimeout(runCheck, 150); setTimeout(runCheck, 600); // Persistent light watchdog while tab is active this._headerGestureWatchId = setInterval(runCheck, 2000); // Auto-stop after 10 minutes to avoid permanent background work if (this._headerGestureWatchStopTimer) { clearTimeout(this._headerGestureWatchStopTimer); } this._headerGestureWatchStopTimer = setTimeout(() => { try { if (this._headerGestureWatchId) clearInterval(this._headerGestureWatchId); } catch (_) {} this._headerGestureWatchId = null; }, 10 * 60 * 1000); } catch (_) {} } // Navigate to adjacent chapter (delta: -1 previous, +1 next), across books navigateAdjacentChapter(tabId, delta) { try { const tab = this.tabs.find(t => t.id === tabId); if (!tab || tab.mode !== 'bible' || !tab.book || !tab.chapter) return; // Close any open popups when changing chapters try { if (typeof closeWordPopup === 'function') { closeWordPopup(); } } catch (e) { console.error('Error closing popups on chapter change:', e); } const books = Object.keys(bibleStructure); let bookIndex = books.indexOf(tab.book); if (bookIndex < 0) return; let newChapter = Number(tab.chapter) + (delta < 0 ? -1 : 1); let newBook = tab.book; if (newChapter < 1) { if (bookIndex === 0) return; // at very beginning bookIndex -= 1; newBook = books[bookIndex]; newChapter = bibleStructure[newBook].length; } else { const maxCh = bibleStructure[newBook]?.length || 1; if (newChapter > maxCh) { if (bookIndex >= books.length - 1) return; // at very end bookIndex += 1; newBook = books[bookIndex]; newChapter = 1; } } tab.book = newBook; tab.chapter = newChapter; tab.verse = 1; tab.verseEnd = null; tab.isSelectingVerses = false; tab._pendingScrollRestore = false; // Load the new chapter runAutomaticOperation(() => { this.loadPassage(tabId); }); } catch (_) {} } // Persist the current vertical scroll position for a Bible tab saveScrollPosition(tabId) { try { const tab = this.tabs.find(t => t.id === tabId); if (!tab || tab.mode !== 'bible') return; const readingArea = document.getElementById(`readingArea${tabId}`); if (!readingArea) return; const y = readingArea.scrollTop || 0; tab.scrollTop = y; } catch (_) {} } // Restore saved vertical scroll position for a Bible tab (with gentle retries) restoreScrollPosition(tabId) { try { const tab = this.tabs.find(t => t.id === tabId); if (!tab || tab.mode !== 'bible') return; const apply = () => { const ra = document.getElementById(`readingArea${tabId}`); if (!ra) return false; const target = Math.max(0, Number(tab.scrollTop || 0)); if (target > 0 && ra.scrollTop !== target) { ra.scrollTop = target; } return true; }; // Try now, then on next frames to cover async renders if (!apply()) { requestAnimationFrame(() => { apply(); }); setTimeout(() => { apply(); }, 60); setTimeout(() => { apply(); }, 200); } } catch (_) {} } switchTab(tabId) { console.log('[DEBUG] Switching to tab:', tabId); const requestedTab = this.tabs.find(t => t.id === tabId); if (!requestedTab) return; // Never activate hidden tabs (e.g. when tab visibility toggles are off). if (!this.isTabVisible(requestedTab)) { const firstVisibleTab = this.tabs.find(t => this.isTabVisible(t)); if (firstVisibleTab) { tabId = firstVisibleTab.id; } else { runAutomaticOperation(() => { this.createNewTab(); }); return; } } // Capture previous tab ID before switching (needed for pen color logic) const prevId = this.activeTabId; // Save scroll of the previously active Bible tab before switching away try { if (prevId && prevId !== tabId) { const prevTab = this.tabs.find(t => t.id === prevId); const newTab = this.tabs.find(t => t.id === tabId); // Leaving a media tab: stop any playing carousel video (audio must not continue underneath). try { if (prevTab && (prevTab.mode === 'graphics' || prevTab.mode === 'maps')) { document.querySelectorAll('#readingArea' + prevId + ' video').forEach(v => { try { v.pause(); } catch (_) {} }); } } catch (_) {} // If switching from bible tab to alpha tab, remember the passage and current verse if (prevTab && prevTab.mode === 'bible' && newTab && newTab.mode === 'alpha') { // Remember the passage from the bible tab newTab._rememberedPassage = { book: prevTab.book, chapter: prevTab.chapter, verse: prevTab.verse, verseEnd: prevTab.verseEnd, translation: prevTab.translation }; // Copy the passage to alpha tab newTab.book = prevTab.book; newTab.chapter = prevTab.chapter; newTab.verse = prevTab.verse; newTab.verseEnd = prevTab.verseEnd; newTab.translation = prevTab.translation; // Get the current visible verse from the bible tab and remember it for auto-scroll try { const ra = document.getElementById(`readingArea${prevId}`); const btEl = document.getElementById(`bibleText${prevId}`); if (ra && btEl) { let headerOffset = 0; try { const header = document.getElementById(`chapterHeader${prevId}`); 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; } } }); // Store the current verse so alpha tab will auto-scroll to it newTab._scrollToVerse = currentVerse; console.log('[ALPHA TAB] Remembered verse', currentVerse, 'from bible tab'); } } catch (e) { console.warn('[ALPHA TAB] Could not get current verse:', e); } } if (prevTab && prevTab.mode === 'bible') { this.saveScrollPosition(prevId); } // If leaving Notes tab, restore prior pen color if (prevTab && prevTab.mode === 'notes' && __prevPenColorBeforeNotes != null) { try { penColor = __prevPenColorBeforeNotes; window.penColor = penColor; __prevPenColorBeforeNotes = null; if (typeof updateToolUI === 'function') updateToolUI(); } catch (_) {} } // If leaving Whiteboard tab, restore prior pen color if (prevTab && prevTab.mode === 'whiteboard' && __prevPenColorBeforeWhiteboard != null) { try { penColor = __prevPenColorBeforeWhiteboard; window.penColor = penColor; __prevPenColorBeforeWhiteboard = null; if (typeof updateToolUI === 'function') updateToolUI(); } catch (_) {} } } } catch (_) {} // Update active tab this.activeTabId = tabId; // Tell Companion straight away which tab is up — from the booth this is the only way to see it. try { scheduleCompanionActiveTabFeedback(0); } catch (_) {} // Hide popups from inactive tabs and show popups for active tab try { this.tabs.forEach(tab => { const readingArea = document.getElementById(`readingArea${tab.id}`); if (readingArea) { const tabPopups = readingArea.querySelectorAll('.word-info-popup'); if (tab.id === tabId) { // Show popups for active tab tabPopups.forEach(popup => { popup.style.display = ''; popup.style.visibility = ''; }); } else { // Hide popups for inactive tabs (but keep them in DOM) tabPopups.forEach(popup => { popup.style.display = 'none'; }); } } }); } catch (e) { console.warn('[TAB SWITCH] Error managing popups:', e); } // Update tab UI this.renderTabs(); // Safety: ensure exactly one active tab is visually marked try { const tabsEls = document.querySelectorAll('.tab'); const active = String(this.activeTabId); tabsEls.forEach(el => { el.classList.toggle('active', String(el.dataset.tabId) === active); }); } catch (_) {} // Show the selected tab content (with a light container fade) const tabContentEl = document.querySelector('.tab-content'); if (tabContentEl) { try { tabContentEl.classList.remove('tab-container-fade-enter', 'tab-container-fade-enter-active'); // Force reflow to restart animation void tabContentEl.offsetWidth; tabContentEl.classList.add('tab-container-fade-enter'); } catch (_) {} } const tab = this.tabs.find(t => t.id === tabId); if (tab) { this.renderTabContent(tab); // After rendering, ensure popups for this tab are shown setTimeout(() => { try { const readingArea = document.getElementById(`readingArea${tabId}`); if (readingArea) { const tabPopups = readingArea.querySelectorAll('.word-info-popup'); tabPopups.forEach(popup => { popup.style.display = ''; popup.style.visibility = ''; // Ensure popup is in activeWordPopups array if (activeWordPopups.indexOf(popup) === -1) { activeWordPopups.push(popup); } }); } } catch (e) { console.warn('[TAB SWITCH] Error showing popups after render:', e); } }, 50); if (tabContentEl) { try { requestAnimationFrame(() => { tabContentEl.classList.add('tab-container-fade-enter-active'); }); tabContentEl.addEventListener('transitionend', () => { tabContentEl.classList.remove('tab-container-fade-enter', 'tab-container-fade-enter-active'); }, { once: true }); } catch (_) {} } // Announcements tab refresh is handled in renderTabContent itself // No need for duplicate render call // Force pen color by tab type: // � Notes tab: set to Black for high-contrast writing if (tab.mode === 'notes') { try { if (__prevPenColorBeforeNotes == null) __prevPenColorBeforeNotes = penColor; penColor = '#000000'; window.penColor = penColor; if (typeof updateToolUI === 'function') updateToolUI(); } catch (_) {} } else if (tab.mode === 'whiteboard') { // � Whiteboard tab: default to White (preserve and restore prior color) try { if (__prevPenColorBeforeWhiteboard == null) __prevPenColorBeforeWhiteboard = penColor; penColor = '#fefefe'; window.penColor = penColor; if (typeof updateToolUI === 'function') updateToolUI(); } catch (_) {} } else if (tab.mode === 'bible') { // � Bible tab: auto-switch to yellow pen when coming from Whiteboard or Slides (media) tabs // Use requestAnimationFrame to ensure this runs AFTER any OSC commands that might be queued try { if (prevId && prevId !== tabId) { const prevTab = this.tabs.find(t => t.id === prevId); if (prevTab && (prevTab.mode === 'whiteboard' || prevTab.mode === 'media')) { // Mark that we're switching to Bible tab from Whiteboard/Slides (for OSC handler to respect) // Set flag immediately to block OSC commands window._bibleTabAutoYellow = true; // Set yellow color with delay to ensure it overrides any OSC commands requestAnimationFrame(() => { penColor = '#ffff00'; window.penColor = penColor; if (typeof updateToolUI === 'function') updateToolUI(); // Clear the flag after a delay to allow tab switch color to take priority // Longer delay ensures OSC commands don't override tab-specific behavior setTimeout(() => { window._bibleTabAutoYellow = false; }, 500); }); } else { // NOT switching from Whiteboard/Slides - clear any lingering flag and preserve current color // This ensures OSC-set colors are preserved when switching to Bible tab from other tabs if (window._bibleTabAutoYellow) { window._bibleTabAutoYellow = false; } // Ensure the current pen color is properly synced and applied to the UI // Use requestAnimationFrame to ensure it happens after any pending updates requestAnimationFrame(() => { // Sync penColor variable with window.penColor if it exists if (window.penColor && window.penColor !== penColor) { penColor = window.penColor; } // Ensure both are in sync window.penColor = penColor; // Update UI to reflect the current pen color const penBtn = window.penBtn || document.getElementById('penBtn'); if (penBtn) { penBtn.style.setProperty('--pen-color', penColor); penBtn.style.color = penColor; } if (typeof updateToolUI === 'function') { updateToolUI(); } }); } } else { // No previous tab or same tab - clear flag and preserve color if (window._bibleTabAutoYellow) { window._bibleTabAutoYellow = false; } } } catch (_) {} } // Attempt early scroll restoration (DOM exists now; content may follow) if (tab.mode === 'bible' || tab.mode === 'alpha') { try { this.releaseGlobalTouchBlocks(); } catch (_) {} if (tab.mode === 'bible') { this.restoreScrollPosition(tabId); } try { this.startHeaderGestureWatch(tabId); } catch (_) {} try { this.ensureBibleInputReady(tabId); } catch (_) {} } // The highlighter NEVER survives a tab switch (any tab -> any tab). Go through the REAL // pen button so the full tool-switch path runs (canvas modes, listeners, UI) — setting // currentTool alone leaves the highlighter machinery active. try { if (window.currentTool === 'highlight' || (typeof currentTool !== 'undefined' && currentTool === 'highlight')) { runAutomaticOperation(() => { const pb = window.penBtn || document.getElementById('penToolBtn'); if (pb && typeof pb.click === 'function') pb.click(); else if (typeof handleToolSelection === 'function') handleToolSelection('pen'); }); } // Belt-and-suspenders: after the switch settles, verify the ACTUAL drawing settings // (module scope) really left highlight; if anything restored it, force pen and log it. const enforcePen = () => { try { const s = (typeof getCurrentDrawingSettings === 'function') ? getCurrentDrawingSettings() : null; const stillHighlight = (typeof currentTool !== 'undefined' && currentTool === 'highlight') || (s && s.alpha === 0.3); if (stillHighlight) { console.warn('[TOOL RESET] Highlight survived a tab switch — forcing pen (please report what you did before this)'); runAutomaticOperation(() => { if (typeof currentTool !== 'undefined') currentTool = 'pen'; window.currentTool = 'pen'; if (typeof updateToolUI === 'function') updateToolUI(); if (typeof updateCanvasPointerEvents === 'function') updateCanvasPointerEvents(); }); } } catch (_) {} }; setTimeout(enforcePen, 120); setTimeout(enforcePen, 600); } catch (_) {} // Auto-switch tools based on tab type if (tab.mode === 'alpha') { // Alpha tab: auto-switch to select tool try { runAutomaticOperation(() => { window.currentTool = 'select'; if (typeof currentTool !== 'undefined') currentTool = 'select'; window.selectModeActive = true; if (typeof enableWordSelection === 'function') { enableWordSelection(); } if (typeof updateToolUI === 'function') { updateToolUI(); } }); } catch (_) {} } else if (tab.mode === 'bible') { // Bible tab: auto-switch to pen tool try { runAutomaticOperation(() => { // Disable word selection if it was active if (window.selectModeActive) { if (typeof disableWordSelection === 'function') { disableWordSelection(); } window.selectModeActive = false; if (typeof selectModeActive !== 'undefined') selectModeActive = false; } // Switch to pen tool window.currentTool = 'pen'; if (typeof currentTool !== 'undefined') currentTool = 'pen'; if (typeof updateToolUI === 'function') { updateToolUI(); } }); } catch (_) {} } else if (tab.mode === 'whiteboard' || tab.mode === 'notes') { // Whiteboard and Notes tabs: auto-switch to pen tool (color already set above) try { runAutomaticOperation(() => { // Disable word selection if it was active if (window.selectModeActive) { if (typeof disableWordSelection === 'function') { disableWordSelection(); } window.selectModeActive = false; if (typeof selectModeActive !== 'undefined') selectModeActive = false; } // Switch to pen tool window.currentTool = 'pen'; if (typeof currentTool !== 'undefined') currentTool = 'pen'; if (typeof updateToolUI === 'function') { updateToolUI(); } }); } catch (_) {} } else if (tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps') { // Announcements/Graphics/Maps: tools otherwise unchanged (highlighter reset above). try { runAutomaticOperation(() => { if (typeof updateToolUI === 'function') { updateToolUI(); } }); } catch (_) {} } else if (tab.mode === 'prayer') { // Prayer tab: disable all drawing tools - text only try { runAutomaticOperation(() => { // Disable word selection if it was active if (window.selectModeActive) { if (typeof disableWordSelection === 'function') { disableWordSelection(); } window.selectModeActive = false; if (typeof selectModeActive !== 'undefined') selectModeActive = false; } // Keep a standard tool selected while prayer tab stays text-only window.currentTool = 'pen'; if (typeof currentTool !== 'undefined') currentTool = 'pen'; if (typeof updateToolUI === 'function') { updateToolUI(); } }); } catch (_) {} } // Update tool UI to reflect current tab // For Bible tabs, ensure pen color is properly applied (especially after OSC commands) if (tab.mode === 'bible') { requestAnimationFrame(() => { // Sync penColor with window.penColor to ensure OSC-set colors are preserved if (window.penColor && window.penColor !== penColor) { penColor = window.penColor; } window.penColor = penColor; // Ensure pen button displays the correct color const penBtn = window.penBtn || document.getElementById('penBtn'); if (penBtn) { penBtn.style.setProperty('--pen-color', penColor); penBtn.style.color = penColor; } }); } if (typeof updateToolUI === 'function') { updateToolUI(); } } // SMOOTH: Optimized setup without flickering delays if (tab.mode === 'bible') { // ProPresenter Sync tab: the link may have moved it while it sat in the background // (propresenter-sync.js sets book/chapter/verse and clears loaded/savedText), so load that // passage now instead of showing the book selector. Everything else is plain Bible-tab flow. const ppsyncMovedInBackground = !!(tab.ppsync && !tab.loaded && tab.book && tab.chapter); if (ppsyncMovedInBackground) { runAutomaticOperation(() => { this.loadPassage(tab.id); }); } else if (tab.loaded && (tab.savedText || (tab.book && tab.chapter && tab.verse))) { // If this tab previously loaded content, restore it immediately if (tab._panelReused) { tab._panelReused = false; // live panel re-attached — its DOM is already current, skip the HTML rebuild } else if (tab.savedText) { this.restoreTabContent(tab); } else { // Fallback: reload passage to repopulate DOM runAutomaticOperation(() => { // Mark that we want to restore scroll after this same-passage load tab._pendingScrollRestore = true; this.loadPassage(tab.id); }); } } // IMMEDIATE: Setup annotations without delay for smoother transition setupAnnotationForActiveTab(); try { if (tab.ppsync && window.PPSync && typeof window.PPSync.onTabShown === 'function') window.PPSync.onTabShown(tab.id, ppsyncMovedInBackground); } catch (_) {} // Ensure pen ready after content becomes available (covers startup) setTimeout(() => forcePenReadyForTab(tabId), 80); setTimeout(() => ensurePenDrawingReady(tabId), 140); setTimeout(() => ensureCanvasAndPenReady(tabId), 260); setTimeout(() => ensureNonZeroCanvas(tabId), 320); // Ensure header gestures and unblockers are applied after content exists try { this.installHeaderGestures(tab); } catch (_) {} try { this.startHeaderGestureWatch(tab.id); } catch (_) {} try { this.ensureBibleInputReady(tab.id); } catch (_) {} // Load annotations for loaded Bible tabs (popups restored by loadAnnotationsInstant) if (tab.loaded && tab.book && tab.chapter && tab.verse) { loadAnnotations(tabId); // Ensure popups are shown after annotations are loaded setTimeout(() => { try { const readingArea = document.getElementById(`readingArea${tabId}`); if (readingArea) { const tabPopups = readingArea.querySelectorAll('.word-info-popup'); tabPopups.forEach(popup => { popup.style.display = ''; popup.style.visibility = ''; // Ensure popup is in activeWordPopups array if (activeWordPopups.indexOf(popup) === -1) { activeWordPopups.push(popup); } }); } } catch (e) { console.warn('[TAB SWITCH] Error showing popups after load:', e); } }, 150); } // Safety: ensure pen tool and canvas are ready shortly after switching in from other tabs (esp. announcements) setTimeout(() => { try { window.currentTool = 'pen'; if (typeof currentTool !== 'undefined') currentTool = 'pen'; if (typeof updateToolUI === 'function') updateToolUI(); setupAnnotationForActiveTab(); } catch (_) {} }, 120); } else if (tab.mode === 'alpha') { // Alpha tab: auto-load the remembered passage from bible tab if (tab.book && tab.chapter && tab.verse) { // Load the passage automatically runAutomaticOperation(() => { this.loadPassage(tab.id); }); } else if (tab._rememberedPassage && tab._rememberedPassage.book) { // Use remembered passage if available tab.book = tab._rememberedPassage.book; tab.chapter = tab._rememberedPassage.chapter; tab.verse = tab._rememberedPassage.verse; tab.verseEnd = tab._rememberedPassage.verseEnd; tab.translation = tab._rememberedPassage.translation; runAutomaticOperation(() => { this.loadPassage(tab.id); }); } // IMMEDIATE: Setup annotations without delay for smoother transition setupAnnotationForActiveTab(); // Ensure header gestures and unblockers are applied after content exists try { this.installHeaderGestures(tab); } catch (_) {} try { this.startHeaderGestureWatch(tab.id); } catch (_) {} try { this.ensureBibleInputReady(tab.id); } catch (_) {} // Load annotations for loaded Alpha tabs (popups restored by loadAnnotationsInstant) if (tab.loaded && tab.book && tab.chapter && tab.verse) { loadAnnotations(tabId); } } else if (tab.mode === 'media') { // SMOOTH: Batch canvas removal and media initialization const readingArea = document.getElementById(`readingArea${tabId}`); if (readingArea) { const annotationCanvases = readingArea.querySelectorAll('.annotation-canvas, .annotation-overlay-canvas'); annotationCanvases.forEach(canvas => { // SMOOTH: Fade out before removal canvas.style.opacity = '0'; canvas.style.transition = 'opacity 0.1s ease'; setTimeout(() => canvas.remove(), 100); console.log('[SMOOTH] Fading out annotation canvas from media tab:', tabId); }); } // IMMEDIATE: Initialize media tab without delay this.initializeMediaTab(tabId); // Start continuous refresh for the active media tab this.startMediaTabMonitoring(tabId); // Ensure drawing canvas exists immediately (no delay) so user can draw right away setTimeout(() => { try { if (window.localMediaHandler && typeof window.localMediaHandler.ensureDrawingCanvasExists === 'function') { window.localMediaHandler.ensureDrawingCanvasExists(tabId); console.log('[MEDIA DRAW] ensureDrawingCanvasExists called right after media init'); } } catch (e) { console.warn('[MEDIA DRAW] ensureDrawingCanvasExists error:', e); } }, 0); } else if (tab.mode === 'whiteboard') { // Ensure annotation canvases and listeners are set up for whiteboard setupAnnotationForActiveTab(); } else if (tab.mode === 'notes' || tab.mode === 'announcements') { // Ensure annotation canvases and listeners are set up for notes/announcements tab setupAnnotationForActiveTab(); } else if (tab.mode === 'graphics') { // Graphics: image-only carousel. Skip annotation setup; nothing else to do here // because renderTabContent initializes the carousel itself. } } closeTab(tabId) { // Find the index of the tab being closed const closedTabIndex = this.tabs.findIndex(t => t.id === tabId); const tabToClose = this.tabs.find(t => t.id === tabId); if (tabToClose && tabToClose.ppsync) { console.log('[PPSYNC] The ProPresenter Sync tab cannot be closed'); return; } try { window._lastTabCloseAt = Date.now(); window._suppressResetAppUntil = window._lastTabCloseAt + 700; } catch (_) {} // Don't record tab closing - UI state changes shouldn't be undoable this.tabs = this.tabs.filter(t => t.id !== tabId); // Clean up annotation canvases and cache for this tab if (annotationCanvases[tabId]) { delete annotationCanvases[tabId]; } if (annotationOffscreen[tabId]) { delete annotationOffscreen[tabId]; } if (this._announcementTimers && this._announcementTimers[tabId]) { clearInterval(this._announcementTimers[tabId]); delete this._announcementTimers[tabId]; } // Clear the Notes/PDF file-watch interval so it doesn't keep polling + retaining the closed tab if (tabToClose && tabToClose._notesWatcher) { try { clearInterval(tabToClose._notesWatcher); } catch (_) {} tabToClose._notesWatcher = null; } // Teardown observers/intervals to prevent leaks try { if (this._mediaCanvasObserver && this._mediaCanvasObserver.disconnect) { this._mediaCanvasObserver.disconnect(); this._mediaCanvasObserver = null; } } catch (_) {} try { if (this._mediaPlusIntervalId) { clearInterval(this._mediaPlusIntervalId); this._mediaPlusIntervalId = null; } } catch (_) {} try { if (window._localMediaButtonIntervalId) { clearInterval(window._localMediaButtonIntervalId); window._localMediaButtonIntervalId = null; } } catch (_) {} try { if (contentResizeObservers[tabId]) { contentResizeObservers[tabId].disconnect(); delete contentResizeObservers[tabId]; } if (contentResizeRaf[tabId]) { cancelAnimationFrame(contentResizeRaf[tabId]); delete contentResizeRaf[tabId]; } } catch (_) {} if (this.tabs.length === 0) { // Create new tab automatically without recording it as a separate operation runAutomaticOperation(() => { this.createNewTab(); }); } else { // Determine which tab to switch to let nextTabIndex = 0; if (closedTabIndex >= 0 && closedTabIndex < this.tabs.length) { // If we closed a tab that wasn't the last one, stay at the same index nextTabIndex = closedTabIndex; } else if (closedTabIndex >= this.tabs.length) { // If we closed the last tab, go to the previous tab nextTabIndex = this.tabs.length - 1; } const nextTab = this.tabs[nextTabIndex]; this.activeTabId = nextTab.id; this.renderTabs(); // renderTabs() may redirect activeTabId if selected tab is hidden. const activeTab = this.tabs.find(t => t.id === this.activeTabId) || nextTab; this.renderTabContent(activeTab); // Automatically load passage if the tab was previously loaded if (activeTab.loaded && activeTab.book && activeTab.chapter && activeTab.verse) { console.log('[DEBUG] Auto-loading passage for tab:', activeTab.id); runAutomaticOperation(() => { this.loadPassage(activeTab.id); }); } } } renderTabContent(tab) { const tabContent = document.querySelector('.tab-content'); // FAST PATH (bible only): if this tab's panel was already built for the SAME passage, // re-attach the live element instead of rebuilding the whole chapter DOM — word listeners // and annotation canvases survive, making the switch near-instant. Kill switch: // localStorage.fastBibleSwitch = 'off'. In-tab navigation changes the key → full rebuild. try { if (tab.mode === 'bible' && tab.loaded && tab._panelEl && localStorage.getItem('fastBibleSwitch') !== 'off') { const key = [tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation].join('|'); const bt = tab._panelEl.querySelector(`#bibleText${tab.id}`); if (tab._panelKey === key && bt && bt.textContent && bt.textContent.trim().length > 0) { tabContent.innerHTML = ''; tab._panelEl.classList.remove('tab-fade-enter', 'tab-fade-enter-active'); tab._panelEl.classList.add('active'); tabContent.appendChild(tab._panelEl); tab._panelReused = true; return; } } } catch (_) {} // Teardown any per-render observers/intervals before clearing try { if (this._mediaCanvasObserver && this._mediaCanvasObserver.disconnect) { this._mediaCanvasObserver.disconnect(); this._mediaCanvasObserver = null; } } catch (_) {} try { if (this._mediaPlusIntervalId) { clearInterval(this._mediaPlusIntervalId); this._mediaPlusIntervalId = null; } } catch (_) {} try { if (window._localMediaButtonIntervalId) { clearInterval(window._localMediaButtonIntervalId); window._localMediaButtonIntervalId = null; } } catch (_) {} // Force clear any cached content for announcements/graphics/notes tabs if (tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'notes') { // Clear any cached image/PDF data and force reload try { const imgEl = document.getElementById(`mediaImage${tab.id}`); // Force image reload by clearing src and adding cache bust if (imgEl && imgEl.src) { const currentSrc = imgEl.src.split('?')[0]; imgEl.src = currentSrc + '?reload=' + Date.now(); } if (imgEl) { imgEl.src = ''; imgEl.removeAttribute('src'); } } catch (_) {} } tabContent.innerHTML = ''; const panelElement = document.createElement('div'); // Start hidden to allow a quick fade-in when switching tabs panelElement.className = 'tab-panel active tab-fade-enter'; panelElement.dataset.tabId = tab.id; if (tab.mode === 'whiteboard') { // Whiteboard: black background content that can extend as needed const initialHeight = Math.max(window.innerHeight * 2, tab.whiteboardHeight || 4000); tab.whiteboardHeight = initialHeight; panelElement.innerHTML = `
`; // Lazy height expansion to simulate unlimited scroll try { const ra = document.getElementById(`readingArea${tab.id}`); const bt = document.getElementById(`bibleText${tab.id}`); if (ra && bt && !ra._wbScrollBound) { ra.addEventListener('scroll', () => { try { const nearBottom = (ra.scrollTop + ra.clientHeight) > (ra.scrollHeight - Math.max(ra.clientHeight, 1200)); if (nearBottom) { const increment = Math.max(ra.clientHeight * 1.5, 2000); tab.whiteboardHeight = Math.min(20000000, (tab.whiteboardHeight || initialHeight) + increment); bt.style.height = `${tab.whiteboardHeight}px`; } } catch (_) {} }, { passive: true }); ra._wbScrollBound = true; } } catch (_) {} } else if (tab.mode === 'prayer') { // Prayer tab: editable text area styled like bible text const initialHeight = Math.max(window.innerHeight * 2, 4000); // Load saved content - only use non-empty content let savedContent = getPrayerContentSnapshot(tab) || ''; if (!savedContent || !savedContent.trim()) { savedContent = ''; } // Ensure empty strings stay empty (don't convert empty to
) tab.prayerContent = savedContent; if (savedContent && savedContent.trim()) { persistPrayerContent(tab.id, savedContent); } // Check if saved content is HTML or plain text let displayContent = savedContent; if (savedContent && savedContent.trim() && !savedContent.includes('<')) { // Plain text - convert line breaks to
displayContent = savedContent.replace(/\n/g, '
'); } else if (!savedContent || !savedContent.trim()) { // Ensure empty content stays empty displayContent = ''; } panelElement.innerHTML = `
${displayContent}
`; // Prevent annotation canvas creation for prayer tab try { const readingArea = document.getElementById(`readingArea${tab.id}`); if (readingArea) { readingArea.style.setProperty('pointer-events', 'auto', 'important'); // Remove any existing annotation canvases const canvases = readingArea.querySelectorAll('canvas'); canvases.forEach(canvas => { if (canvas.id && (canvas.id.includes('annotation') || canvas.id.includes('drawing'))) { canvas.style.display = 'none'; canvas.style.pointerEvents = 'none'; } }); } } catch (_) {} // Initialize prayer tab functionality setTimeout(() => { this.initializePrayerTab(tab.id); }, 100); } else if (tab.mode === 'alpha' || tab.mode === 'bible') { // Alpha and Bible tabs: same structure, render bible text const initialHeight = Math.max(window.innerHeight * 2, 4000); panelElement.innerHTML = `
Translation:
${tab.loaded ? `
` : ''}
`; if (tab.ppsync) { // Deliberately nothing extra on screen: the sync tab IS a Bible tab; propresenter-sync.js only // decides WHICH passage it shows. Configuration and link state live in Settings. panelElement.classList.add('ppsync-panel'); setTimeout(() => { try { if (window.PPSync && typeof window.PPSync.onPanelRendered === 'function') window.PPSync.onPanelRendered(tab.id); } catch (_) {} }, 0); } } else if (tab.mode === 'ask') { // Ask tab: voice-driven AI assistant. Single-stream UI \u2014 the mic // is at the top, and the AI's response is written directly into the // topmost history bubble so there is no duplication of content. panelElement.innerHTML = `
`; setTimeout(() => { try { initializeAskTab(this, tab.id); } catch (err) { console.error('[ASK] init failed:', err); } }, 50); } else if (tab.mode === 'announcements') { console.log('[ANNOUNCEMENTS TAB] Rendering announcements tab for tab:', tab.id); panelElement.innerHTML = `
`; const ensureAnnouncementStyles = () => { if (announcementStylesInjected) return; const style = document.createElement('style'); style.id = 'announcement-carousel-styles'; style.textContent = ` .announcements-carousel { position: relative; width: 100%; height: 100vh; min-height: 600px; overflow: visible; touch-action: none; } .announcement-track { position: absolute; inset: 0; } .announcement-card { position: absolute; inset: 0; border-radius: 0; display: flex; align-items: center; justify-content: center; background: #000 center/cover no-repeat; box-shadow: 0 0 0 500vh #000; opacity: 0; transform: translateY(10px) scale(0.98); transition: transform 420ms cubic-bezier(0.25, 0.8, 0.4, 1), opacity 420ms cubic-bezier(0.25, 0.8, 0.4, 1); } .announcement-card img { max-width: 100%; max-height: 100%; width: auto; height: auto; pointer-events: none; user-select: none; } .announcement-wrapper, .announcement-wrapper-inner { padding: 0 !important; margin: 0 !important; background: #000 !important; border: 0 !important; } .announcements-carousel, .announcement-track, .announcement-card, .announcement-card img { background: #000; } .announcement-live { position: absolute; left: -9999px; top: -9999px; } .announcement-empty { color: #bfbfbf; text-align: center; padding: 48px 16px; } @media (prefers-reduced-motion: reduce) { .announcement-card { transition: opacity 200ms ease; transform: none !important; } } `; document.head.appendChild(style); announcementStylesInjected = true; }; const setLive = (text) => { const live = document.getElementById(`announcementLive${tab.id}`); if (live) live.textContent = text || ''; }; const initializeCarousel = async () => { if (this.activeTabId !== tab.id) return; ensureAnnouncementStyles(); const carousel = document.getElementById(`announcementCarousel${tab.id}`); const track = document.getElementById(`announcementTrack${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); const prefersReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!carousel || !track) return; const items = await this.fetchOrderedAnnouncements(8); if (!items || !items.length) { track.innerHTML = '
No announcements available.
'; setLive('No announcements available.'); return; } track.innerHTML = ''; let currentIndex = 0; let animating = false; let pointerId = null; let startX = 0; let startY = 0; let locked = null; let touchStartX = null; let touchStartY = null; let lastDragY = 0; // one-finger / mouse-drag vertical scroll tracking const savedIndex = getSavedAnnouncementIndex(tab.id); const slides = items.map((item, idx) => { const card = document.createElement('div'); card.className = 'announcement-card'; card.setAttribute('role', 'group'); card.setAttribute('aria-label', `Announcement ${idx + 1} of ${items.length}${item.title ? `: ${item.title}` : ''}`); card.dataset.index = idx; const img = document.createElement('img'); img.src = item.url; img.alt = item.alt || item.title || 'Announcement'; img.loading = 'lazy'; // Transparent artwork (a traced SVG is black-on-nothing) is invisible on the black // card — check once decoded and give it paper only if it needs it. if (img.complete && img.naturalWidth) applyPaperBackdropIfNeeded(img); else img.addEventListener('load', () => applyPaperBackdropIfNeeded(img), { once: true }); card.appendChild(img); track.appendChild(card); return card; }); const setSlideState = (slide, offset, animate) => { if (!slide) return; const isCurrent = offset === 0; const opacity = 1; // NEVER opacity<1 on a slide: opacity:0 promotes the hidden card to a GPU layer whose bottom edge bleeds the seam during scroll. Hidden slides use visibility:hidden only. const scale = prefersReducedMotion ? 1 : (isCurrent ? 1 : 0.98); slide.style.transition = animate ? (prefersReducedMotion ? 'opacity 200ms ease' : 'transform 360ms cubic-bezier(0.25, 0.8, 0.4, 1), opacity 360ms cubic-bezier(0.25, 0.8, 0.4, 1)') : 'none'; // Keep ALL slides transform:none at rest. A resting transform (even scale(0.98) on a // hidden card) promotes it to a GPU layer whose bottom edge sits at the carousel // bottom and bleeds a 1px light seam during scroll. Slides cross-fade via opacity. slide.style.transform = 'none'; slide.style.opacity = opacity; slide.style.zIndex = isCurrent ? 10 : 1; slide.style.pointerEvents = isCurrent ? 'auto' : 'none'; slide.style.visibility = isCurrent ? 'visible' : 'hidden'; // A video must never keep playing on a hidden slide (no-op for image slides). if (!isCurrent) { try { const v = slide.querySelector('video'); if (v && !v.paused) v.pause(); } catch (_) {} } }; const announce = () => { const item = items[currentIndex]; setLive(`Announcement ${currentIndex + 1} of ${items.length}${item?.title ? `: ${item.title}` : ''}`); }; const goTo = (targetIndex, direction = 1, userInitiated = false) => { if (animating || targetIndex === currentIndex || targetIndex < 0 || targetIndex >= slides.length) return; animating = true; // Optional crossfade (Settings > Presentation). Default 0 = instant hard cut (unchanged). let xf = 0; try { xf = parseInt(localStorage.getItem('carouselCrossfadeMs'), 10) || 0; } catch (_) { xf = 0; } xf = Math.max(0, Math.min(1000, xf)); const outgoing = slides[currentIndex]; if (xf > 0 && !prefersReducedMotion && outgoing && outgoing !== slides[targetIndex]) { // Incoming rests at opacity:1 BENEATH; fade only the OUTGOING card's opacity 1->0 // on top to reveal it, then snap it back to the seam-safe resting state // (opacity:1 + visibility:hidden) so scroll never sees a card at opacity<1. slides.forEach((slide, idx) => { if (slide !== outgoing) setSlideState(slide, idx - targetIndex, false); }); outgoing.style.transform = 'none'; outgoing.style.visibility = 'visible'; outgoing.style.zIndex = 11; outgoing.style.pointerEvents = 'none'; outgoing.style.transition = `opacity ${xf}ms ease`; void outgoing.offsetWidth; outgoing.style.opacity = '0'; setTimeout(() => { setSlideState(outgoing, slides.indexOf(outgoing) - targetIndex, false); }, xf + 40); currentIndex = targetIndex; saveAnnouncementIndex(tab.id, currentIndex); announce(); setTimeout(() => { animating = false; }, xf + 60); } else { slides.forEach((slide, idx) => { const offset = idx - targetIndex; setSlideState(slide, offset, true); }); currentIndex = targetIndex; saveAnnouncementIndex(tab.id, currentIndex); announce(); setTimeout(() => { animating = false; }, prefersReducedMotion ? 220 : 420); } }; const goRelative = (delta, userInitiated = false) => { const next = (currentIndex + delta + slides.length) % slides.length; goTo(next, delta >= 0 ? 1 : -1, userInitiated); }; currentIndex = Math.min(Math.max(0, savedIndex), slides.length - 1); slides.forEach((slide, idx) => setSlideState(slide, idx - currentIndex, false)); announce(); /* * Follower hook — the booth screen runs this same carousel and must land on the SAME * image. currentIndex and goTo are closure-local, so they are published on the element * for follower-publish.js to read and follower-view.js to drive. * * The URL is carried alongside the index because the two machines build their lists * independently: if a file is added or reordered mid-service, index 3 could be a * different picture on each screen. The follower matches on URL first and only falls * back to the index. */ try { carousel.__followerCarousel = { get index() { return currentIndex; }, get count() { return slides.length; }, urlAt: (i) => (items[i] && items[i].url) || null, indexOfUrl: (u) => { for (let i = 0; i < items.length; i++) if (items[i] && items[i].url === u) return i; return -1; }, // goTo refuses while a transition is animating; the follower simply re-asserts // on its next snapshot, so a dropped call self-heals within 250ms. goTo: (i) => { if (typeof i !== 'number' || i < 0 || i >= slides.length || i === currentIndex) return false; goTo(i, i > currentIndex ? 1 : -1, false); return true; }, /* * Maps pinch-zoom / pan. zoomScale/panX/panY are closure-local to the graphics+maps * carousel; the announcements carousel shares this hook and has no zoom, hence the * typeof guards (there the getter returns null and setZoom is a no-op). * s = scale, x/y = pan in CSS px — identical geometry on both machines, so they * apply verbatim. */ get zoom() { try { return (typeof zoomScale === 'number') ? { s: zoomScale, x: panX, y: panY } : null; } catch (_) { return null; } }, setZoom: (z) => { try { if (typeof zoomScale !== 'number' || !z) return false; const s = Math.max(1, Math.min(MAX_ZOOM, +z.s || 1)); if (s === zoomScale && (+z.x || 0) === panX && (+z.y || 0) === panY) return false; zoomScale = s; panX = +z.x || 0; panY = +z.y || 0; applyZoomTransform(false); return true; } catch (_) { return false; } } }; } catch (_) {} // Focus the carousel so keyboard arrows work immediately try { setTimeout(() => { if (carousel && this.activeTabId === tab.id) { carousel.focus(); } }, 60); } catch (_) {} const handlePointerDown = (e) => { if (pointerId !== null) return; pointerId = e.pointerId; startX = e.clientX; startY = e.clientY; locked = null; lastDragY = e.clientY; carousel.setPointerCapture?.(pointerId); }; const handlePointerMove = (e) => { if (pointerId === null || e.pointerId !== pointerId) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (!locked) { if (Math.abs(dx) > Math.abs(dy) + 8 && Math.abs(dx) > 10) { locked = 'x'; } else if (Math.abs(dy) > Math.abs(dx) + 8) { locked = 'y'; } } if (locked === 'x') { try { e.preventDefault(); } catch (_) {} } else if (locked === 'y' && readingArea) { // Vertical drag scrolls the image up/down (touch + mouse drag) readingArea.scrollTop -= (e.clientY - lastDragY); try { e.preventDefault(); } catch (_) {} } lastDragY = e.clientY; }; const handlePointerUp = (e) => { if (pointerId === null || e.pointerId !== pointerId) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (locked === 'x' && Math.abs(dx) > 32 && Math.abs(dx) > Math.abs(dy)) { goRelative(dx > 0 ? -1 : 1, true); } pointerId = null; locked = null; carousel.releasePointerCapture?.(e.pointerId); }; carousel.addEventListener('pointerdown', handlePointerDown, { passive: true }); carousel.addEventListener('pointermove', handlePointerMove, { passive: false }); carousel.addEventListener('pointerup', handlePointerUp, { passive: true }); carousel.addEventListener('pointercancel', handlePointerUp, { passive: true }); // Mouse-wheel vertical scrolling (reading-area is overflow:hidden, so scroll it manually) if (readingArea) { readingArea.addEventListener('wheel', (e) => { readingArea.scrollTop += e.deltaY; try { e.preventDefault(); } catch (_) {} }, { passive: false }); } // Touch fallback (for environments where pointer events are limited) carousel.addEventListener('touchstart', (e) => { const t = e.touches && e.touches[0]; if (!t) return; touchStartX = t.clientX; touchStartY = t.clientY; locked = null; }, { passive: true }); carousel.addEventListener('touchmove', (e) => { const t = e.touches && e.touches[0]; if (!t || touchStartX === null || touchStartY === null) return; const dx = t.clientX - touchStartX; const dy = t.clientY - touchStartY; if (!locked) { if (Math.abs(dx) > Math.abs(dy) + 6 && Math.abs(dx) > 12) { locked = 'x'; } else if (Math.abs(dy) > Math.abs(dx) + 6) { locked = 'y'; } } if (locked === 'x') { try { e.preventDefault(); } catch (_) {} } }, { passive: false }); carousel.addEventListener('touchend', (e) => { if (touchStartX === null || touchStartY === null) return; const t = e.changedTouches && e.changedTouches[0]; if (!t) return; const dx = t.clientX - touchStartX; const dy = t.clientY - touchStartY; if (locked === 'x' && Math.abs(dx) > 28 && Math.abs(dx) > Math.abs(dy)) { goRelative(dx > 0 ? -1 : 1, true); } touchStartX = null; touchStartY = null; locked = null; }, { passive: true }); // CRITICAL FIX: Defensive handling for keyboard disconnect events - prevent black screen bug carousel.addEventListener('keydown', (e) => { try { // Defensive check: ensure event object is valid before accessing properties if (!e || typeof e !== 'object' || typeof e.key !== 'string') return; if (e.key === 'ArrowLeft') { e.preventDefault(); goRelative(-1, true); } else if (e.key === 'ArrowRight') { e.preventDefault(); goRelative(1, true); } else if (e.key === 'Home') { e.preventDefault(); goTo(0, -1, true); } else if (e.key === 'End') { e.preventDefault(); goTo(slides.length - 1, 1, true); } } catch (err) { // Silently handle any errors from malformed keyboard events (e.g., keyboard disconnect) console.warn('[KEYBOARD] Carousel key handler error (likely keyboard disconnect):', err); } }); // Global arrow handling while announcements tab is active (helps if focus is lost) // CRITICAL FIX: Defensive handling for keyboard disconnect events - prevent black screen bug const globalArrowHandler = (e) => { try { // Defensive check: ensure event object is valid before accessing properties if (!e || typeof e !== 'object' || typeof e.key !== 'string') return; if (this.activeTabId !== tab.id) return; if (e.key === 'ArrowLeft') { e.preventDefault(); goRelative(-1, true); } else if (e.key === 'ArrowRight') { e.preventDefault(); goRelative(1, true); } } catch (err) { // Silently handle any errors from malformed keyboard events (e.g., keyboard disconnect) console.warn('[KEYBOARD] Global arrow handler error (likely keyboard disconnect):', err); } }; window.addEventListener('keydown', globalArrowHandler, { passive: false }); // Cleanup when carousel is removed const cleanup = () => { window.removeEventListener('keydown', globalArrowHandler, { passive: false }); try { observer.disconnect(); } catch (_) {} }; const observer = new MutationObserver((mutations) => { mutations.forEach((m) => { if (m.removedNodes) { m.removedNodes.forEach((n) => { if (n === carousel || (n.contains && n.contains(carousel))) { cleanup(); } }); } }); }); try { observer.observe(document.body, { childList: true, subtree: true }); } catch (_) {} }; // Use setTimeout to ensure DOM is ready setTimeout(() => { initializeCarousel(); try { ensureContentResizeObserver(tab.id); } catch (_) {} }, 50); // Small delay to ensure DOM is ready } else if (tab.mode === 'graphics' || tab.mode === 'maps') { // Graphics & Maps: identical image-first swipeable carousel; only the data source differs // (/api/graphics/list + /Graphics/ vs /api/maps/list + /Maps/). Mirrors the Announcements pattern. const mediaCat = tab.mode === 'maps' ? 'maps' : 'graphics'; console.log(`[${mediaCat.toUpperCase()} TAB] Rendering ${mediaCat} tab for tab:`, tab.id); panelElement.innerHTML = `
`; // (applyPaperBackdropIfNeeded is defined at module scope — see the note there.) const ensureGraphicsStyles = () => { if (graphicsStylesInjected) return; const style = document.createElement('style'); style.id = 'graphics-carousel-styles'; style.textContent = ` .graphics-carousel { position: relative; width: 100%; height: 100vh; min-height: 600px; overflow: visible; touch-action: none; overscroll-behavior: none; } .graphics-track { position: absolute; inset: 0; touch-action: none; } .graphics-card { position: absolute; inset: 0; border-radius: 0; display: flex; align-items: center; justify-content: center; background: #000 center/cover no-repeat; box-shadow: 0 0 0 500vh #000; opacity: 0; transform: translateY(10px) scale(0.98); transition: transform 420ms cubic-bezier(0.25, 0.8, 0.4, 1), opacity 420ms cubic-bezier(0.25, 0.8, 0.4, 1); } /* Leave this rule ALONE for SVG. max+auto/auto is the one combination that keeps the aspect ratio of a viewBox-only vector in BOTH orientations: Chromium's default sizing algorithm scales the ratio to fit the card. Pinning width:100% (or height:100%) stretches whichever orientation does not match the screen, and object-fit is banned here (it reintroduces the GPU edge fringe). */ .graphics-card img, .graphics-card video { max-width: 100%; max-height: 100%; width: auto; height: auto; pointer-events: none; user-select: none; touch-action: none; } /* Set ONLY on transparent, dark artwork (see applyPaperBackdropIfNeeded): traced SVGs are black paint on nothing, which is invisible on a black card. More specific than the background:#000 rule below, so it wins. Plain background on the IMG — no filter/ transform/object-fit — so it cannot promote a layer or bring back the card-edge fringe. */ .graphics-card img.needs-paper { background: #ffffff; } .graphics-wrapper, .graphics-wrapper-inner { padding: 0 !important; margin: 0 !important; background: #000 !important; border: 0 !important; touch-action: none; overscroll-behavior: none; } .graphics-carousel, .graphics-track, .graphics-card, .graphics-card img, .graphics-card video { background: #000; touch-action: none; } .graphics-live { position: absolute; left: -9999px; top: -9999px; } .graphics-empty { color: #bfbfbf; text-align: center; padding: 48px 16px; font-family: Georgia, serif; } @media (prefers-reduced-motion: reduce) { .graphics-card { transition: opacity 200ms ease; transform: none !important; } } `; document.head.appendChild(style); graphicsStylesInjected = true; }; const setLive = (text) => { const live = document.getElementById(`graphicsLive${tab.id}`); if (live) live.textContent = text || ''; }; const initializeGraphicsCarousel = async () => { if (this.activeTabId !== tab.id) return; ensureGraphicsStyles(); const carousel = document.getElementById(`graphicsCarousel${tab.id}`); const track = document.getElementById(`graphicsTrack${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); const prefersReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!carousel || !track) return; const items = await this.fetchOrderedMedia(mediaCat, 16); if (!items || !items.length) { track.innerHTML = `
No ${mediaCat === 'maps' ? 'maps or timelines' : 'graphics'} available.
Add images via the Content Manager.
`; setLive(`No ${mediaCat === 'maps' ? 'maps or timelines' : 'graphics'} available.`); return; } track.innerHTML = ''; let currentIndex = 0; let animating = false; let pointerId = null; let startX = 0; let startY = 0; let locked = null; let touchStartX = null; let touchStartY = null; let lastDragY = 0; // one-finger / mouse-drag vertical scroll tracking const slideScrollTops = {}; // per-slide scroll isolation: scrolling one graphic never carries to another const savedIndex = getSavedGraphicIndex(tab.id); const slides = items.map((item, idx) => { const card = document.createElement('div'); card.className = 'graphics-card'; card.setAttribute('role', 'group'); card.setAttribute('aria-label', `Graphic ${idx + 1} of ${items.length}${item.title ? `: ${item.title}` : ''}`); card.dataset.index = idx; const mediaBase = String(item.url || '').split('?')[0]; if (/\.(mp4|m4v|webm|mov)$/i.test(mediaBase)) { // Video slide: fills like an image; a simple TAP toggles play/pause. const vid = document.createElement('video'); vid.src = item.url; vid.preload = 'metadata'; vid.playsInline = true; vid.setAttribute('playsinline', ''); vid.controls = false; vid.loop = !!(item.raw && item.raw.loop); // per-video Loop toggle set in the Content Manager card.appendChild(vid); } else { const img = document.createElement('img'); img.src = item.url; img.alt = item.alt || item.title || 'Graphic'; img.loading = 'lazy'; // Transparent artwork (a traced SVG is black-on-nothing) is invisible on the black // card — check once decoded and give it paper only if it needs it. if (img.complete && img.naturalWidth) applyPaperBackdropIfNeeded(img); else img.addEventListener('load', () => applyPaperBackdropIfNeeded(img), { once: true }); card.appendChild(img); } track.appendChild(card); return card; }); // ---- Pinch-to-zoom + pan (Graphics tab) ---- // Applies a CSS transform to the CURRENT slide's . Only active on this carousel; // the graphics tab has no annotation canvas and no two-finger scroll, so nothing else // competes for these gestures. let zoomScale = 1, panX = 0, panY = 0; let isPinching = false, pinchStartDist = 0, pinchStartScale = 1, pinchFocalX = 0, pinchFocalY = 0, pinchStartPanX = 0, pinchStartPanY = 0; let pinchCenterX = 0, pinchCenterY = 0; // carousel centre cached at pinch start (avoids per-frame layout) let pinchRaf = 0; // rAF id: batch pinch updates to once per frame (kills interleaved-event jitter) let isImagePanning = false, panPointerStartX = 0, panPointerStartY = 0, panOrigStartX = 0, panOrigStartY = 0; let panSamples = []; // recent pointer samples while panning zoomed — used to detect a fast flick const MAX_ZOOM = 5; const getCurrentImg = () => { const s = slides[currentIndex]; return s ? s.querySelector('img') : null; }; const touchDist = (a, b) => Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); const clampPan = () => { const rect = carousel.getBoundingClientRect(); const maxX = Math.max(0, (zoomScale - 1) * rect.width / 2); const maxY = Math.max(0, (zoomScale - 1) * rect.height / 2); panX = Math.max(-maxX, Math.min(maxX, panX)); panY = Math.max(-maxY, Math.min(maxY, panY)); }; const applyZoomTransform = (animate) => { const img = getCurrentImg(); if (!img) return; img.style.transition = animate ? 'transform 200ms ease' : 'none'; img.style.transformOrigin = 'center center'; img.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomScale})`; }; const resetZoom = (animate) => { zoomScale = 1; panX = 0; panY = 0; isPinching = false; isImagePanning = false; slides.forEach(s => { const im = s.querySelector('img'); if (im) { im.style.transition = animate ? 'transform 200ms ease' : 'none'; im.style.transform = ''; } }); }; const setSlideState = (slide, offset, animate) => { if (!slide) return; const isCurrent = offset === 0; const opacity = 1; // NEVER opacity<1 on a slide: opacity:0 promotes the hidden card to a GPU layer whose bottom edge bleeds the seam during scroll. Hidden slides use visibility:hidden only. const scale = prefersReducedMotion ? 1 : (isCurrent ? 1 : 0.98); slide.style.transition = animate ? (prefersReducedMotion ? 'opacity 200ms ease' : 'transform 360ms cubic-bezier(0.25, 0.8, 0.4, 1), opacity 360ms cubic-bezier(0.25, 0.8, 0.4, 1)') : 'none'; // Keep ALL slides transform:none at rest. A resting transform (even scale(0.98) on a // hidden card) promotes it to a GPU layer whose bottom edge sits at the carousel // bottom and bleeds a 1px light seam during scroll. Slides cross-fade via opacity. slide.style.transform = 'none'; slide.style.opacity = opacity; slide.style.zIndex = isCurrent ? 10 : 1; slide.style.pointerEvents = isCurrent ? 'auto' : 'none'; slide.style.visibility = isCurrent ? 'visible' : 'hidden'; // A video must never keep playing on a hidden slide (no-op for image slides). if (!isCurrent) { try { const v = slide.querySelector('video'); if (v && !v.paused) v.pause(); } catch (_) {} } }; const announce = () => { const item = items[currentIndex]; setLive(`Graphic ${currentIndex + 1} of ${items.length}${item?.title ? `: ${item.title}` : ''}`); }; const goTo = (targetIndex, direction = 1, userInitiated = false) => { if (animating || targetIndex === currentIndex || targetIndex < 0 || targetIndex >= slides.length) return; if (isPinching || zoomScale > 1) return; // don't navigate while pinching or zoomed (zoom out first) animating = true; // Per-slide scroll isolation: stash this slide's scroll, restore the target's own (top by default). try { if (readingArea) { slideScrollTops[currentIndex] = readingArea.scrollTop || 0; readingArea.scrollTop = slideScrollTops[targetIndex] || 0; } } catch (_) {} // Optional crossfade (Settings > Presentation). Default 0 = instant hard cut (unchanged). let xf = 0; try { xf = parseInt(localStorage.getItem('carouselCrossfadeMs'), 10) || 0; } catch (_) { xf = 0; } xf = Math.max(0, Math.min(1000, xf)); const outgoing = slides[currentIndex]; if (xf > 0 && !prefersReducedMotion && outgoing && outgoing !== slides[targetIndex]) { // Incoming rests at opacity:1 BENEATH; fade only the OUTGOING card's opacity 1->0 // on top to reveal it, then snap it back to the seam-safe resting state // (opacity:1 + visibility:hidden) so scroll never sees a card at opacity<1. slides.forEach((slide, idx) => { if (slide !== outgoing) setSlideState(slide, idx - targetIndex, false); }); outgoing.style.transform = 'none'; outgoing.style.visibility = 'visible'; outgoing.style.zIndex = 11; outgoing.style.pointerEvents = 'none'; outgoing.style.transition = `opacity ${xf}ms ease`; void outgoing.offsetWidth; outgoing.style.opacity = '0'; setTimeout(() => { setSlideState(outgoing, slides.indexOf(outgoing) - targetIndex, false); }, xf + 40); currentIndex = targetIndex; saveGraphicIndex(tab.id, currentIndex); announce(); setTimeout(() => { animating = false; }, xf + 60); } else { slides.forEach((slide, idx) => { const offset = idx - targetIndex; setSlideState(slide, offset, true); }); currentIndex = targetIndex; saveGraphicIndex(tab.id, currentIndex); announce(); setTimeout(() => { animating = false; }, prefersReducedMotion ? 220 : 420); } }; const goRelative = (delta, userInitiated = false) => { const next = (currentIndex + delta + slides.length) % slides.length; goTo(next, delta >= 0 ? 1 : -1, userInitiated); }; currentIndex = Math.min(Math.max(0, savedIndex), slides.length - 1); slides.forEach((slide, idx) => setSlideState(slide, idx - currentIndex, false)); announce(); /* * Follower hook — the booth screen runs this same carousel and must land on the SAME * image. currentIndex and goTo are closure-local, so they are published on the element * for follower-publish.js to read and follower-view.js to drive. * * The URL is carried alongside the index because the two machines build their lists * independently: if a file is added or reordered mid-service, index 3 could be a * different picture on each screen. The follower matches on URL first and only falls * back to the index. */ try { carousel.__followerCarousel = { get index() { return currentIndex; }, get count() { return slides.length; }, urlAt: (i) => (items[i] && items[i].url) || null, indexOfUrl: (u) => { for (let i = 0; i < items.length; i++) if (items[i] && items[i].url === u) return i; return -1; }, // goTo refuses while a transition is animating; the follower simply re-asserts // on its next snapshot, so a dropped call self-heals within 250ms. goTo: (i) => { if (typeof i !== 'number' || i < 0 || i >= slides.length || i === currentIndex) return false; goTo(i, i > currentIndex ? 1 : -1, false); return true; }, /* * Maps pinch-zoom / pan. zoomScale/panX/panY are closure-local to the graphics+maps * carousel; the announcements carousel shares this hook and has no zoom, hence the * typeof guards (there the getter returns null and setZoom is a no-op). * s = scale, x/y = pan in CSS px — identical geometry on both machines, so they * apply verbatim. */ get zoom() { try { return (typeof zoomScale === 'number') ? { s: zoomScale, x: panX, y: panY } : null; } catch (_) { return null; } }, setZoom: (z) => { try { if (typeof zoomScale !== 'number' || !z) return false; const s = Math.max(1, Math.min(MAX_ZOOM, +z.s || 1)); if (s === zoomScale && (+z.x || 0) === panX && (+z.y || 0) === panY) return false; zoomScale = s; panX = +z.x || 0; panY = +z.y || 0; applyZoomTransform(false); return true; } catch (_) { return false; } } }; } catch (_) {} try { setTimeout(() => { if (carousel && this.activeTabId === tab.id) { carousel.focus(); } }, 60); } catch (_) {} // ---- Unified Pointer-Events gestures (work identically on the TV and a touchscreen laptop) ---- // Pointer Events fire for touch on every Chromium/Electron device, so one code path covers // touch, pen and mouse. touch-action:none (CSS, on the carousel AND the card/track/img the // touch actually lands on) stops the browser from claiming two-finger gestures as scroll. const activePointers = new Map(); // pointerId -> { x, y } let swipeActive = false; const pointsArray = () => Array.from(activePointers.values()); const beginPinch = () => { const p = pointsArray(); if (p.length < 2) return; isPinching = true; swipeActive = false; isImagePanning = false; locked = null; pinchStartDist = Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y) || 1; pinchStartScale = zoomScale; pinchStartPanX = panX; pinchStartPanY = panY; const rect = carousel.getBoundingClientRect(); const midX = (p[0].x + p[1].x) / 2, midY = (p[0].y + p[1].y) / 2; pinchCenterX = rect.left + rect.width / 2; pinchCenterY = rect.top + rect.height / 2; pinchFocalX = midX - pinchCenterX; pinchFocalY = midY - pinchCenterY; }; const onPointerDown = (e) => { // Fresh gesture: clear any state stranded by a missed pointerup (palm rejection, lift off-screen) if (activePointers.size === 0) { isPinching = false; isImagePanning = false; swipeActive = false; } activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); try { carousel.setPointerCapture(e.pointerId); } catch (_) {} // Pinch-zoom is MAPS-ONLY: Graphics stays a simple swipe/tap surface (incl. videos). if (activePointers.size === 2) { if (mediaCat === 'maps') beginPinch(); return; } if (activePointers.size > 2) return; if (zoomScale > 1) { isImagePanning = true; panPointerStartX = e.clientX; panPointerStartY = e.clientY; panOrigStartX = panX; panOrigStartY = panY; panSamples = [{ t: Date.now(), x: e.clientX, y: e.clientY }]; swipeActive = false; } else { swipeActive = true; startX = e.clientX; startY = e.clientY; locked = null; lastDragY = e.clientY; } }; const onPointerMove = (e) => { if (!activePointers.has(e.pointerId)) return; activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (isPinching && activePointers.size >= 2) { // Batch the pinch transform to ONCE PER FRAME using both fingers' latest positions. // Computing it on every interleaved single-finger pointermove makes the finger // distance (and thus zoom) oscillate while dragging -> the jitter. rAF fixes that. if (!pinchRaf) { pinchRaf = requestAnimationFrame(() => { pinchRaf = 0; if (!isPinching || activePointers.size < 2) return; const p = pointsArray(); const d = Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y) || 1; let target = pinchStartScale * (d / pinchStartDist); target = Math.max(1, Math.min(MAX_ZOOM, target)); let newScale = zoomScale + (target - zoomScale) * 0.5; if (Math.abs(newScale - target) < 0.004) newScale = target; // low-pass: no zoom jitter during 2-finger pan const midX = (p[0].x + p[1].x) / 2 - pinchCenterX; const midY = (p[0].y + p[1].y) / 2 - pinchCenterY; const focalImgX = (pinchFocalX - pinchStartPanX) / pinchStartScale; const focalImgY = (pinchFocalY - pinchStartPanY) / pinchStartScale; panX = midX - newScale * focalImgX; panY = midY - newScale * focalImgY; zoomScale = newScale; if (zoomScale <= 1.001) { zoomScale = 1; panX = 0; panY = 0; } clampPan(); applyZoomTransform(false); }); } try { e.preventDefault(); } catch (_) {} return; } if (isImagePanning && zoomScale > 1) { panX = panOrigStartX + (e.clientX - panPointerStartX) * 1.6; // amplify: less finger travel on the TV panY = panOrigStartY + (e.clientY - panPointerStartY) * 1.6; panSamples.push({ t: Date.now(), x: e.clientX, y: e.clientY }); if (panSamples.length > 8) panSamples.shift(); clampPan(); applyZoomTransform(false); try { e.preventDefault(); } catch (_) {} return; } if (swipeActive) { const dx = e.clientX - startX, dy = e.clientY - startY; if (!locked) { if (Math.abs(dx) > Math.abs(dy) + 8 && Math.abs(dx) > 10) locked = 'x'; else if (Math.abs(dy) > Math.abs(dx) + 8) locked = 'y'; } if (locked === 'x') { try { e.preventDefault(); } catch (_) {} } else if (locked === 'y' && zoomScale === 1 && readingArea) { // Vertical drag scrolls the image up/down when not zoomed (touch + mouse drag) readingArea.scrollTop -= (e.clientY - lastDragY) * 1.6; // amplify vertical drag = less reach try { e.preventDefault(); } catch (_) {} } lastDragY = e.clientY; } }; const endPointer = (e) => { const wasPinching = isPinching; if (activePointers.has(e.pointerId)) activePointers.delete(e.pointerId); try { carousel.releasePointerCapture(e.pointerId); } catch (_) {} if (wasPinching) { if (activePointers.size < 2) { isPinching = false; if (zoomScale <= 1.001) { resetZoom(true); } else { applyZoomTransform(true); if (activePointers.size === 1 && zoomScale > 1) { const p = pointsArray()[0]; isImagePanning = true; panPointerStartX = p.x; panPointerStartY = p.y; panOrigStartX = panX; panOrigStartY = panY; } } } return; } if (isImagePanning) { if (activePointers.size === 0) { isImagePanning = false; // Fast horizontal FLICK while zoomed => change slide (no need to zoom out first). try { panSamples.push({ t: Date.now(), x: e.clientX, y: e.clientY }); const recent = panSamples.filter(s => (Date.now() - s.t) <= 160); if (recent.length >= 2) { const a = recent[0], b = recent[recent.length - 1]; const dt = Math.max(1, b.t - a.t); const vx = (b.x - a.x) / dt; // px per ms const vy = (b.y - a.y) / dt; if (Math.abs(vx) > 0.9 && Math.abs(vx) > Math.abs(vy) * 1.4) { resetZoom(false); goRelative(vx > 0 ? -1 : 1, true); } } } catch (_) {} panSamples = []; } return; } if (swipeActive) { swipeActive = false; const dx = e.clientX - startX, dy = e.clientY - startY; if (locked === 'x' && Math.abs(dx) > 32 && Math.abs(dx) > Math.abs(dy)) { goRelative(dx > 0 ? -1 : 1, true); } else if (!locked && Math.abs(dx) < 8 && Math.abs(dy) < 8) { // Simple TAP: if the current slide is a video, toggle play/pause (no-op for images). try { const v = slides[currentIndex] && slides[currentIndex].querySelector('video'); if (v) { if (v.paused) { v.play().catch(() => {}); } else { v.pause(); } } } catch (_) {} } locked = null; } }; const cancelPointer = (e) => { if (activePointers.has(e.pointerId)) activePointers.delete(e.pointerId); try { carousel.releasePointerCapture(e.pointerId); } catch (_) {} if (activePointers.size === 0) { isPinching = false; isImagePanning = false; swipeActive = false; locked = null; if (zoomScale <= 1.001) resetZoom(false); } }; carousel.addEventListener('pointerdown', onPointerDown, { passive: true }); carousel.addEventListener('pointermove', onPointerMove, { passive: false }); carousel.addEventListener('pointerup', endPointer, { passive: true }); carousel.addEventListener('pointercancel', cancelPointer, { passive: true }); // Mouse-wheel vertical scrolling (reading-area is overflow:hidden; scroll it manually). // Ignored while pinching/zoomed so the wheel never fights the zoom view. if (readingArea) { readingArea.addEventListener('wheel', (e) => { if (isPinching || zoomScale > 1) return; readingArea.scrollTop += e.deltaY; try { e.preventDefault(); } catch (_) {} }, { passive: false }); } carousel.addEventListener('keydown', (e) => { try { if (!e || typeof e !== 'object' || typeof e.key !== 'string') return; if (e.key === 'ArrowLeft') { e.preventDefault(); goRelative(-1, true); } else if (e.key === 'ArrowRight') { e.preventDefault(); goRelative(1, true); } else if (e.key === 'Home') { e.preventDefault(); goTo(0, -1, true); } else if (e.key === 'End') { e.preventDefault(); goTo(slides.length - 1, 1, true); } } catch (err) { console.warn('[KEYBOARD] Graphics carousel key handler error:', err); } }); const globalArrowHandler = (e) => { try { if (!e || typeof e !== 'object' || typeof e.key !== 'string') return; if (this.activeTabId !== tab.id) return; if (e.key === 'ArrowLeft') { e.preventDefault(); goRelative(-1, true); } else if (e.key === 'ArrowRight') { e.preventDefault(); goRelative(1, true); } } catch (err) { console.warn('[KEYBOARD] Global arrow handler error (graphics):', err); } }; window.addEventListener('keydown', globalArrowHandler, { passive: false }); const cleanup = () => { window.removeEventListener('keydown', globalArrowHandler, { passive: false }); try { observer.disconnect(); } catch (_) {} }; const observer = new MutationObserver((mutations) => { mutations.forEach((m) => { if (m.removedNodes) { m.removedNodes.forEach((n) => { if (n === carousel || (n.contains && n.contains(carousel))) { cleanup(); } }); } }); }); try { observer.observe(document.body, { childList: true, subtree: true }); } catch (_) {} }; setTimeout(() => { initializeGraphicsCarousel(); try { ensureContentResizeObserver(tab.id); } catch (_) {} }, 50); } else if (tab.mode === 'notes') { // Notes: black background, render two PDF pages stacked vertically panelElement.innerHTML = `
`; // One render at a time: this runs through several awaits (list, fileinfo, pdf.js, the document), and a // fast Notes → Bible → Notes flick starts a second run while the first is still awaiting. Without the // stamp the first run wakes to the second run's freshly built container and appends its pages too. const notesRenderSeq = (tab._notesRenderSeq = (tab._notesRenderSeq || 0) + 1); const notesRenderStale = () => tab._notesRenderSeq !== notesRenderSeq || (window.bibleApp && window.bibleApp.activeTabId !== tab.id); (async () => { try { // Choose PDF in this priority: // 1) filename in Notes/default.txt (from content manager) // 2) if exactly ONE .pdf exists, use that // 3) otherwise, use the most recently modified .pdf // 4) fallback to sample filename let rawName = ''; try { // First try to get from API which reads default.txt const lr = await fetch(`/api/notes/list?t=${Date.now()}`, { cache: 'no-store' }); if (lr.ok) { const data = await lr.json(); const files = (data && Array.isArray(data.files) ? data.files : []); // Use currentDefault from API if available if (data.currentDefault) { rawName = data.currentDefault; } else if (files.length === 1) { rawName = files[0].name; } else if (files.length > 1) { // Use the first one (already sorted by mtime, newest first) rawName = files[0].name; } } } catch (_) {} // Fallback: try reading default.txt directly if (!rawName) { try { const r = await fetch(`/Notes/default.txt?t=${Date.now()}`, { cache: 'no-store' }); if (r.ok) { const text = (await r.text()).trim(); if (text) { rawName = text; } } } catch (_) {} } if (!rawName) rawName = 'Notes.png'; const isImage = /\.(png|jpe?g|gif|bmp|webp)$/i.test(rawName); let servedUrl = `/Notes/${encodeURIComponent(rawName)}`; try { const infoResp = await fetch(`/api/notes/fileinfo?file=${encodeURIComponent(rawName)}&t=${Date.now()}`, { cache: 'no-store' }); if (infoResp.ok) { const info = await infoResp.json(); const v = info && (info.mtimeMs || info.size || Date.now()); servedUrl = `/Notes/${encodeURIComponent(rawName)}?v=${encodeURIComponent(v)}`; } } catch (_) {} // Quick availability check to avoid throwing inside pdf.js try { const head = await fetch(`${servedUrl}&t=${Date.now()}`, { method: 'HEAD', cache: 'no-store' }); if (!head.ok) { console.error('[NOTES] Asset not reachable:', servedUrl, 'Status:', head.status); throw new Error(`Notes asset not reachable: ${head.status} ${head.statusText}`); } } catch (e) { console.error('[NOTES] Asset availability check failed:', e); throw new Error(`Notes asset not reachable: ${e.message}`); } if (notesRenderStale()) return; if (isImage) { const container = document.getElementById(`bibleText${tab.id}`); const readingArea = document.getElementById(`readingArea${tab.id}`); if (container) { container.innerHTML = ''; const img = document.createElement('img'); img.src = servedUrl.includes('?') ? `${servedUrl}&img=${Date.now()}` : `${servedUrl}?img=${Date.now()}`; img.alt = rawName; // Edge to edge, like an announcement: the sheet IS the page. Every black margin around it // showed on the follower's program feed — the notes were the one tab with a border. img.style.display = 'block'; img.style.margin = '0'; img.style.width = '100%'; img.style.maxWidth = '100%'; img.style.height = 'auto'; container.style.display = 'block'; container.style.padding = '0'; container.style.width = '100%'; container.style.minHeight = 'auto'; if (readingArea) { readingArea.style.display = 'block'; readingArea.style.overflow = 'auto'; readingArea.style.padding = '0'; } container.appendChild(img); try { setupAnnotationForActiveTab(); } catch (_) {} } return; } // Add cache busting to servedUrl if (!servedUrl.includes('?')) { servedUrl += `?t=${Date.now()}`; } else { servedUrl += `&t=${Date.now()}`; } let pdfjsLib; try { pdfjsLib = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.8.69/build/pdf.min.mjs'); if (pdfjsLib && pdfjsLib.GlobalWorkerOptions) { pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.8.69/build/pdf.worker.min.mjs'; } } catch (e) { console.warn('pdfjs CDN failed; trying local', e); try { pdfjsLib = await import('/node_modules/pdfjs-dist/build/pdf.min.mjs'); if (pdfjsLib && pdfjsLib.GlobalWorkerOptions) { pdfjsLib.GlobalWorkerOptions.workerSrc = '/node_modules/pdfjs-dist/build/pdf.worker.min.mjs'; } } catch (e2) { console.error('pdfjs load failed', e2); // Fallback: embed native viewer so at least content is visible const container = document.getElementById(`bibleText${tab.id}`); if (container) { container.innerHTML = ''; const embed = document.createElement('iframe'); embed.src = servedUrl + '#view=FitH'; embed.style.width = '100%'; embed.style.border = 'none'; embed.style.minHeight = '1800px'; container.appendChild(embed); // Allow drawing overlay even with iframe (overlay stays on top of reading-area) try { setupAnnotationForActiveTab(); } catch (_) {} } return; } } const loadingTask = pdfjsLib.getDocument({ url: servedUrl, disableWorker: true }); const pdf = await loadingTask.promise; if (notesRenderStale()) return; const total = Math.min(2, pdf.numPages || 0); const container = document.getElementById(`bibleText${tab.id}`); if (!container) return; container.innerHTML = ''; // never stack a second render's pages under a first // Block layout, no padding: pages abut and run edge to edge (see the image branch above) — the // program feed shows the sheet, not a black frame around it. (getOrCreateAnnotationCanvas flips // this container to block later anyway; saying so here removes the ambiguity.) container.style.display = 'block'; container.style.flexDirection = ''; container.style.alignItems = ''; container.style.gap = '0'; container.style.padding = '0'; // Raster at the panel's own pixel density (capped at 2): the stage TV is DPR 2, and a page drawn // at CSS-pixel size was scaled up 2x on the glass — soft text on a 75" screen. CSS sizes stay in // CSS px, so ink coordinates are untouched; a DPR-1 booth screen renders at 1x as before. const pageDpr = Math.min(window.devicePixelRatio || 1, 2); for (let i = 1; i <= total; i++) { const page = await pdf.getPage(i); const reading = document.getElementById(`readingArea${tab.id}`); const available = Math.max(1, container.clientWidth || (reading ? reading.clientWidth : window.innerWidth)); // full width, no side margin const vp = page.getViewport({ scale: 1 }); const cssScale = available / vp.width; const svp = page.getViewport({ scale: cssScale * pageDpr }); const canvas = document.createElement('canvas'); canvas.width = Math.round(svp.width); canvas.height = Math.round(svp.height); canvas.style.width = `${available}px`; // flush to both edges — never a 1px strip canvas.style.height = `${Math.round(svp.height / pageDpr)}px`; canvas.style.background = '#ffffff'; const ctx = canvas.getContext('2d', { alpha: false }); await page.render({ canvasContext: ctx, viewport: svp }).promise; if (notesRenderStale()) return; container.appendChild(canvas); } try { setupAnnotationForActiveTab(); } catch (_) {} } catch (e) { console.error('Notes PDF render failed', e); try { const container = document.getElementById(`bibleText${tab.id}`); if (container) { const errorMsg = e && e.message ? e.message : 'Unknown error'; container.innerHTML = `
Unable to load PDF: ${escapeAskHtml(rawName)}
Error: ${escapeAskHtml(errorMsg)}
Please check that the file exists in the Notes folder.
`; } } catch (_) {} } })(); } else if (tab.mode === 'media') { // Notes mode: show points directly (no file list). Upload available via keyboard shortcut later if needed. panelElement.innerHTML = `
`; // Aggressively disable all scrolling in media/notes containers try { const ids = [ `mediaSelectorContainer${tab.id}`, `mediaSelectionContent${tab.id}`, `pointsStage${tab.id}`, `pointsCards${tab.id}`, `readingArea${tab.id}`, `contentWrapper${tab.id}`, `mediaText${tab.id}` ]; const isInteractiveTarget = (el) => { try { if (!el || !el.closest) return false; return !!( el.closest('.notes-nav') || el.closest('.slide-scripture-ref') || el.closest('.slide-scripture-badge') || el.closest('.word-info-popup') || el.closest('.word-info-close') || el.closest('.word-info-header') ); } catch (_) { return false; } }; const block = (ev) => { try { if (window._popupDragging) return; // allow active popup drag if (isInteractiveTarget(ev.target)) return; // allow interactive elements ev.preventDefault(); ev.stopPropagation(); } catch (_) {} }; ids.forEach(id => { const el = document.getElementById(id); if (!el) return; el.style.setProperty('overflow', 'hidden', 'important'); el.style.setProperty('touch-action', 'none', 'important'); el.style.setProperty('overscroll-behavior', 'none', 'important'); if (!el._blocksApplied) { el.addEventListener('wheel', block, { passive: false, capture: true }); el.addEventListener('touchstart', block, { passive: false, capture: true }); el.addEventListener('touchmove', block, { passive: false, capture: true }); el.addEventListener('touchend', block, { passive: false, capture: true }); el.addEventListener('gesturestart', block, { passive: false, capture: true }); el.addEventListener('gesturechange', block, { passive: false, capture: true }); el._blocksApplied = true; } }); // Ensure children don't re-enable pan; this covers canvas/text nodes const ra = document.getElementById(`readingArea${tab.id}`); if (ra) { ra.querySelectorAll('*').forEach(node => { try { node.style.setProperty('touch-action', 'none', 'important'); node.style.setProperty('overscroll-behavior', 'none', 'important'); } catch (_) {} }); } } catch (_) {} } else { // Ensure any Slides full-bleed overrides are cleared when showing Bible mode try { document.body.classList.remove('slides-fullbleed'); const mask = document.getElementById('slidesTopMask'); if (mask) mask.remove(); } catch (_) {} // Clear lingering global blockers before rendering Bible UI try { this.releaseGlobalTouchBlocks(); } catch (_) {} // Render bible tab content (original functionality) panelElement.innerHTML = `
Translation:
${tab.loaded ? `
` : ''}
`; // Force pen as default tool in Bible mode (disable select if active) try { if (typeof currentTool !== 'undefined' && (currentTool === 'select' || window.selectModeActive)) { if (typeof disableWordSelection === 'function') { disableWordSelection(); } selectModeActive = false; window.selectModeActive = false; currentTool = 'pen'; window.currentTool = 'pen'; if (typeof updateToolUI === 'function') { updateToolUI(); } if (typeof updateCanvasPointerEvents === 'function') { updateCanvasPointerEvents(); } } } catch (_) {} } tabContent.appendChild(panelElement); // Stash bible panels for fast re-attach on the next switch. Content loads asynchronously // INTO this same element, so the reference stays valid; the key is re-stamped whenever a // passage actually renders (displayPassage / restoreTabContent). try { if (tab.mode === 'bible') { tab._panelEl = panelElement; tab._panelKey = [tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation].join('|'); tab._panelReused = false; } } catch (_) {} // Trigger a very short fade-in once the panel is mounted try { requestAnimationFrame(() => { panelElement.classList.add('tab-fade-enter-active'); }); panelElement.addEventListener('transitionend', () => { panelElement.classList.remove('tab-fade-enter'); panelElement.classList.remove('tab-fade-enter-active'); }, { once: true }); } catch (_) {} // Populate selectors for book/chapter/verse selection (after DOM is ready) if (tab.mode === 'alpha' || tab.mode === 'bible') { this.populateSelectors(tab); } // Ensure scripture references in Bible content are tappable try { if (tab.mode === 'bible' || tab.mode === 'alpha') { setupBibleScripturePopups(tab); } } catch (_) {} // Install per-tab scroll persistence for Bible tabs and try a first restore try { if (tab.mode === 'bible' || tab.mode === 'alpha') { const ra = document.getElementById(`readingArea${tab.id}`); if (ra && !ra._scrollPersistInstalled) { ra.addEventListener('scroll', () => { try { const t = this.tabs.find(tt => tt.id === tab.id); if (t) t.scrollTop = ra.scrollTop || 0; } catch (_) {} }, { passive: true }); ra._scrollPersistInstalled = true; } // Attempt immediate and deferred restoration (covers late layout) this.restoreScrollPosition(tab.id); requestAnimationFrame(() => this.restoreScrollPosition(tab.id)); setTimeout(() => this.restoreScrollPosition(tab.id), 120); } } catch (_) {} // Only populate selectors for bible mode or media notes mode // Also hide/show tool rail based on mode (hide for media/Slides) try { const toolRail = document.getElementById('toolRail'); const toolRailHandle = document.getElementById('toolRailHandle'); const toolRailEdge = document.getElementById('toolRailEdge'); if (tab.mode === 'media') { if (toolRail) toolRail.style.display = 'none'; if (toolRailHandle) toolRailHandle.style.display = 'none'; if (toolRailEdge) toolRailEdge.style.display = 'none'; // Force select tool and disable drawing while in media/Slides try { window.currentTool = 'select'; if (typeof currentTool !== 'undefined') currentTool = 'select'; window.selectModeActive = true; if (typeof selectModeActive !== 'undefined') selectModeActive = true; if (window.updateToolUI) window.updateToolUI(); // Remove any annotation canvases that might have been left over const readingArea = document.getElementById(`readingArea${tab.id}`); if (readingArea) { const ann = readingArea.querySelectorAll('.annotation-canvas, .annotation-overlay-canvas'); ann.forEach(c => c.remove()); } // Block page-level scrolling and gestures globally while in Slides document.documentElement.style.overflow = 'hidden'; document.documentElement.style.overscrollBehavior = 'none'; document.documentElement.style.touchAction = 'none'; document.body.style.overflow = 'hidden'; document.body.style.overscrollBehavior = 'none'; document.body.style.touchAction = 'none'; // Also clamp tab content container const tabContentEl = document.querySelector('.tab-content'); if (tabContentEl) { tabContentEl.style.overflow = 'hidden'; tabContentEl.style.touchAction = 'none'; tabContentEl.style.overscrollBehavior = 'none'; } if (!window._mediaTouchMoveBlocker) { window._mediaTouchMoveBlocker = (e) => { try { const t = e && e.target; if (window._popupDragging) return; // allow active popup drags to proceed if (t && t.closest && ( t.closest('.toolbar') || t.closest('.tab-bar') || t.closest('.tab') || t.closest('.new-tab-btn') || t.closest('.tab-close-confirm') || t.closest('.tab-close-btn') || // Allow scripture interactions on slides t.closest('.slide-scripture-ref') || t.closest('.slide-scripture-badge') || // Allow popup interactions (drag, close, etc.) t.closest('.word-info-popup') )) { // Allow interactions (do not block default/click) return; } e.preventDefault(); e.stopPropagation(); } catch (_) {} }; ['touchstart','touchmove','touchend','gesturestart','gesturechange'].forEach(type => { window.addEventListener(type, window._mediaTouchMoveBlocker, { passive: false, capture: true }); document.addEventListener(type, window._mediaTouchMoveBlocker, { passive: false, capture: true }); try { document.documentElement.addEventListener(type, window._mediaTouchMoveBlocker, { passive: false, capture: true }); } catch (_) {} }); } if (!window._mediaWheelBlocker) { window._mediaWheelBlocker = (e) => { try { const t = e && e.target; if (window._popupDragging) return; // allow dragging without wheel interference if (t && t.closest && ( t.closest('.toolbar') || t.closest('.tab-bar') || t.closest('.tab') || t.closest('.new-tab-btn') || t.closest('.tab-close-confirm') || t.closest('.tab-close-btn') || // Allow scripture interactions on slides t.closest('.slide-scripture-ref') || t.closest('.slide-scripture-badge') || // Allow popup interactions t.closest('.word-info-popup') )) { return; } e.preventDefault(); e.stopPropagation(); } catch (_) {} }; window.addEventListener('wheel', window._mediaWheelBlocker, { passive: false, capture: true }); document.addEventListener('wheel', window._mediaWheelBlocker, { passive: false, capture: true }); try { document.documentElement.addEventListener('wheel', window._mediaWheelBlocker, { passive: false, capture: true }); } catch (_) {} try { document.body.addEventListener('wheel', window._mediaWheelBlocker, { passive: false, capture: true }); } catch (_) {} // Legacy/alt wheel events for some engines try { window.addEventListener('mousewheel', window._mediaWheelBlocker, { passive: false, capture: true }); } catch (_) {} try { document.addEventListener('DOMMouseScroll', window._mediaWheelBlocker, { passive: false, capture: true }); } catch (_) {} } // Add a strong CSS lock to prevent overscroll and scrolling on key containers try { let style = document.getElementById('media-scroll-lock-style'); if (!style) { style = document.createElement('style'); style.id = 'media-scroll-lock-style'; document.head.appendChild(style); } style.textContent = ` html, body, #app, .tab-content, .tab-panel, .reading-area, .content-wrapper { overscroll-behavior: none !important; overflow: clip !important; touch-action: pan-x !important; -ms-touch-action: pan-x !important; -ms-overflow-style: none !important; height: 100vh !important; max-height: 100vh !important; min-height: 0 !important; width: 100vw !important; max-width: 100vw !important; } html, body, #app, .tab-content, .tab-panel { position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; margin: 0 !important; padding: 0 !important; } /* Center slide content and prevent internal scroll */ .reading-area #mediaText${tab.id}, .reading-area #mediaText${tab.id} .passage-text { display: flex !important; align-items: center !important; justify-content: center !important; width: 100vw !important; max-width: 100vw !important; height: 100vh !important; max-height: 100vh !important; overflow: clip !important; touch-action: pan-x !important; } `; } catch (_) {} // Add viewport meta to disable zoom (prevents two-finger scroll-as-zoom) try { let meta = document.querySelector('meta[name="viewport"]').cloneNode ? document.querySelector('meta[name="viewport"]') : null; if (!document.getElementById('media-viewport-lock')) { const m = document.createElement('meta'); m.name = 'viewport'; m.id = 'media-viewport-lock'; m.content = 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'; document.head.appendChild(m); } } catch (_) {} // Force scroll positions to 0 in case any element is still scrolling if (!window._mediaScrollResetter) { window._mediaScrollResetter = () => { try { window.scrollTo(0, 0); document.documentElement.scrollTop = 0; document.body.scrollTop = 0; const elts = [ document.querySelector('.tab-content'), document.querySelector('.tab-panel'), document.getElementById(`readingArea${tab.id}`), document.getElementById(`contentWrapper${tab.id}`) ].filter(Boolean); elts.forEach(el => { el.scrollTop = 0; el.scrollLeft = 0; el.style.overflow = 'hidden'; el.style.maxHeight = '100vh'; el.style.height = '100vh'; }); } catch (_) {} }; document.addEventListener('scroll', window._mediaScrollResetter, true); window.addEventListener('scroll', window._mediaScrollResetter, true); try { document.body.addEventListener('scroll', window._mediaScrollResetter, true); } catch (_) {} } // Extra legacy Windows touch blockers if (!window._mediaLegacyPointerBlockers) { window._mediaLegacyPointerBlockers = []; const legacyEvents = ['MSPointerDown','MSPointerMove','MSPointerUp','MSGestureStart','MSGestureChange']; const handler = (e) => { try { e.preventDefault(); e.stopPropagation(); } catch (_) {} }; legacyEvents.forEach(type => { try { window.addEventListener(type, handler, { passive: false, capture: true }); } catch (_) {} try { document.addEventListener(type, handler, { passive: false, capture: true }); } catch (_) {} try { document.documentElement.addEventListener(type, handler, { passive: false, capture: true }); } catch (_) {} try { document.body.addEventListener(type, handler, { passive: false, capture: true }); } catch (_) {} window._mediaLegacyPointerBlockers.push({ type, handler }); }); } } catch (_) {} } else { if (toolRail) toolRail.style.display = ''; if (toolRailHandle) toolRailHandle.style.display = ''; if (toolRailEdge) toolRailEdge.style.display = ''; // Restore page scroll/touch defaults when not in media try { document.documentElement.style.overflow = ''; document.documentElement.style.overscrollBehavior = ''; document.documentElement.style.touchAction = ''; document.body.style.overflow = ''; document.body.style.overscrollBehavior = ''; document.body.style.touchAction = ''; const tabContentEl = document.querySelector('.tab-content'); if (tabContentEl) { tabContentEl.style.overflow = ''; tabContentEl.style.touchAction = ''; tabContentEl.style.overscrollBehavior = ''; } if (window._mediaTouchMoveBlocker) { ['touchstart','touchmove','touchend','gesturestart','gesturechange'].forEach(type => { window.removeEventListener(type, window._mediaTouchMoveBlocker, { capture: true }); document.removeEventListener(type, window._mediaTouchMoveBlocker, { capture: true }); try { document.documentElement.removeEventListener(type, window._mediaTouchMoveBlocker, { capture: true }); } catch (_) {} }); window._mediaTouchMoveBlocker = null; } if (window._mediaWheelBlocker) { window.removeEventListener('wheel', window._mediaWheelBlocker, { capture: true }); document.removeEventListener('wheel', window._mediaWheelBlocker, { capture: true }); try { document.documentElement.removeEventListener('wheel', window._mediaWheelBlocker, { capture: true }); } catch (_) {} try { document.body.removeEventListener('wheel', window._mediaWheelBlocker, { capture: true }); } catch (_) {} try { window.removeEventListener('mousewheel', window._mediaWheelBlocker, { capture: true }); } catch (_) {} try { document.removeEventListener('DOMMouseScroll', window._mediaWheelBlocker, { capture: true }); } catch (_) {} window._mediaWheelBlocker = null; } if (window._mediaLegacyPointerBlockers && window._mediaLegacyPointerBlockers.length) { try { window._mediaLegacyPointerBlockers.forEach(({ type, handler }) => { try { window.removeEventListener(type, handler, { capture: true }); } catch (_) {} try { document.removeEventListener(type, handler, { capture: true }); } catch (_) {} try { document.documentElement.removeEventListener(type, handler, { capture: true }); } catch (_) {} try { document.body.removeEventListener(type, handler, { capture: true }); } catch (_) {} }); } catch (_) {} window._mediaLegacyPointerBlockers = []; } try { const style = document.getElementById('media-scroll-lock-style'); if (style) style.remove(); } catch (_) {} // Remove the media-only viewport lock so pinch-zoom is restored outside Slides try { const vpLock = document.getElementById('media-viewport-lock'); if (vpLock) vpLock.remove(); } catch (_) {} if (window._mediaScrollResetter) { document.removeEventListener('scroll', window._mediaScrollResetter, true); window.removeEventListener('scroll', window._mediaScrollResetter, true); window._mediaScrollResetter = null; } } catch (_) {} } } catch (_) {} if (tab.mode === 'bible') { this.populateSelectors(tab); // Wire up back-to-selector button when passage is loaded setTimeout(() => { try { const backBtn = document.querySelector(`.header-back-btn[data-action="back-to-selector"][data-tab-id="${tab.id}"]`); // Ensure right spacer matches left cluster width so title never overlaps try { const headerEl = document.getElementById(`chapterHeader${tab.id}`); const leftCluster = headerEl ? headerEl.querySelector('.chapter-left-controls') : null; const rightSpacer = headerEl ? headerEl.querySelector(`#chapterRightSpacer${tab.id}`) : null; const applySpacerWidth = () => { try { if (!leftCluster || !rightSpacer) return; const w = Math.ceil(leftCluster.getBoundingClientRect().width); rightSpacer.style.width = (w > 0 ? w : 40) + 'px'; } catch (_) {} }; applySpacerWidth(); setTimeout(applySpacerWidth, 0); setTimeout(applySpacerWidth, 150); if (window._spacerResizeHandler) { try { window.removeEventListener('resize', window._spacerResizeHandler, { passive: true }); } catch (_) {} } window._spacerResizeHandler = applySpacerWidth; window.addEventListener('resize', applySpacerWidth, { passive: true }); } catch (_) {} if (backBtn) { backBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // Show selector, hide header, clear reading area text const selector = document.getElementById(`selectorContainer${tab.id}`); const header = document.getElementById(`chapterHeader${tab.id}`); const bibleText = document.getElementById(`bibleText${tab.id}`); if (selector) selector.style.display = 'block'; if (header) header.style.display = 'none'; if (bibleText) bibleText.innerHTML = ''; // Mark tab as not loaded to re-enable full selection flow tab.loaded = false; tab.book = ''; tab.chapter = null; tab.verse = null; tab.verseEnd = null; // Re-render selectors to start at books this.populateSelectors(tab); // Update tabs UI if needed this.renderTabs(); }, { once: true }); } // Wire up scroll-to-top button and position it next to the back arrow const topBtn = document.querySelector(`.header-top-btn[data-action="scroll-to-top"][data-tab-id="${tab.id}"]`); if (topBtn) { topBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); try { const ra = document.getElementById(`readingArea${tab.id}`); if (ra) { ra.scrollTop = 0; const bt = document.getElementById(`bibleText${tab.id}`); if (bt) { bt.classList.remove('smooth-fade-in'); void bt.offsetWidth; bt.classList.add('smooth-fade-in'); } } } catch (_) {} }); } // Wire up scroll-to-bottom button and place it to the right of the Top button const downBtn = document.querySelector(`.header-down-btn[data-action="scroll-to-bottom"][data-tab-id="${tab.id}"]`); if (downBtn) { const scrollBottom = (e) => { try { e.preventDefault(); e.stopPropagation(); } catch(_) {} try { const bt = document.getElementById(`bibleText${tab.id}`); const ra = document.getElementById(`readingArea${tab.id}`); if (!bt || !ra) return; let lastVerseNum = null; try { const verseNodes = bt.querySelectorAll('.verse[data-verse]'); verseNodes.forEach(el => { const v = parseInt(el.getAttribute('data-verse') || '0', 10); if (!isNaN(v)) lastVerseNum = (lastVerseNum == null) ? v : Math.max(lastVerseNum, v); }); } catch (_) {} if (typeof lastVerseNum === 'number' && !isNaN(lastVerseNum)) { tab._scrollToVerse = lastVerseNum; try { this.autoScrollToSelectedVerse(tab.id); } catch(_) {} } else { // Fallback if markers not ready yet const maxScroll = Math.max(0, (ra.scrollHeight - ra.clientHeight) || 0); ra.scrollTop = maxScroll; } } catch (_) {} }; downBtn.addEventListener('click', scrollBottom, { passive: false }); downBtn.addEventListener('touchend', scrollBottom, { passive: false }); } // Wire up -5 and +5 buttons; position them in a row after Down const minus5Btn = document.querySelector(`.header-nav-btn[data-action="scroll-up-5"][data-tab-id="${tab.id}"]`); const plus5Btn = document.querySelector(`.header-nav-btn[data-action="scroll-down-5"][data-tab-id="${tab.id}"]`); // Header now uses grid layout; no absolute positioning needed. const onScrollDelta = (delta) => (ev) => { try { ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation && ev.stopImmediatePropagation(); } catch(_) {} 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; } } }); let maxVerses = 1; try { verses.forEach(el => { const v = parseInt(el.getAttribute('data-verse') || '0', 10); if (!isNaN(v)) maxVerses = Math.max(maxVerses, v); }); } catch (_) {} const dest = Math.max(1, Math.min(maxVerses, currentVerse + delta)); tab._scrollToVerse = dest; this.autoScrollToSelectedVerse(tab.id); }; if (minus5Btn && !minus5Btn._wired) { minus5Btn.addEventListener('click', onScrollDelta(-5), { passive: false }); minus5Btn.addEventListener('touchend', onScrollDelta(-5), { passive: false }); minus5Btn._wired = true; } if (plus5Btn && !plus5Btn._wired) { plus5Btn.addEventListener('click', onScrollDelta(5), { passive: false }); plus5Btn.addEventListener('touchend', onScrollDelta(5), { passive: false }); plus5Btn._wired = true; } } catch (_) {} }, 0); // Ensure header swipe-to-navigate is always active try { setTimeout(() => { try { this.installHeaderGestures(tab); } catch (_) {} }, 0); setTimeout(() => { try { this.installHeaderGestures(tab); } catch (_) {} }, 150); setTimeout(() => { try { this.installHeaderGestures(tab); } catch (_) {} }, 600); } catch (_) {} // Proactively clear any global touch blockers after render settles try { setTimeout(() => { try { this.releaseGlobalTouchBlocks(); } catch (_) {} }, 0); setTimeout(() => { try { this.releaseGlobalTouchBlocks(); } catch (_) {} }, 200); setTimeout(() => { try { this.releaseGlobalTouchBlocks(); } catch (_) {} }, 800); } catch (_) {} // Ensure Bible input is fully ready after render settles try { setTimeout(() => { try { this.ensureBibleInputReady(tab.id); } catch (_) {} }, 0); setTimeout(() => { try { this.ensureBibleInputReady(tab.id); } catch (_) {} }, 200); setTimeout(() => { try { this.ensureBibleInputReady(tab.id); } catch (_) {} }, 800); } catch (_) {} } else { // New Notes mode: fetch default points from server and render this.populateDefaultNotes(tab); } } populateSelectors(tab) { // Safety check: ensure selector container exists before populating const selectorContainer = document.getElementById(`selectorContainer${tab.id}`); if (!selectorContainer) { console.warn('[populateSelectors] Selector container not found for tab:', tab.id, 'mode:', tab.mode); return; } this.populateTranslations(tab); this.populateBooks(tab); this.populateChapters(tab); this.populateVerses(tab); // Ensure selector scroll positions are clamped within bounds and not forced away from top unintentionally requestAnimationFrame(() => { try { const selectionContent = document.getElementById(`selectionContent${tab.id}`); if (selectionContent) selectionContent.scrollTop = Math.max(0, selectionContent.scrollTop); const bookCards = document.getElementById(`bookCards${tab.id}`); if (bookCards) bookCards.scrollTop = Math.max(0, bookCards.scrollTop); const chapterCards = document.getElementById(`chapterCards${tab.id}`); if (chapterCards) chapterCards.scrollTop = Math.max(0, chapterCards.scrollTop); const verseCards = document.getElementById(`verseCards${tab.id}`); if (verseCards) verseCards.scrollTop = Math.max(0, verseCards.scrollTop); // Dynamically extend grids to bottom of screen (match passages) without affecting other areas this.adjustSelectorHeights(tab.id); } catch (_) {} }); this.updateStageVisibility(tab); } // Compute available viewport height from the selection content's top and apply to grids adjustSelectorHeights(tabId) { try { const container = document.getElementById(`selectionContent${tabId}`); if (!container) return; const rect = container.getBoundingClientRect(); const availablePx = Math.max(300, Math.floor(window.innerHeight - rect.top - 8)); // Do not clamp container; only adjust inner grids to avoid nested scroll conflicts container.style.removeProperty('max-height'); container.style.removeProperty('height'); // Add generous bottom padding so the last row can scroll to the top // without affecting other app areas. This is touch-TV friendly. const bottomPad = Math.max(Math.floor(availablePx * 0.75), 240); container.style.paddingBottom = bottomPad + 'px'; const apply = (id) => { const el = document.getElementById(`${id}${tabId}`); if (el) { // Let the outer selection-content be the only scroll container el.style.maxHeight = 'none'; el.style.removeProperty('height'); el.style.overflowY = 'visible'; el.style.webkitOverflowScrolling = 'touch'; // Enforce two-finger scroll behavior el.style.touchAction = 'none'; // Also pad the scrollable grid itself so bottom items can reach the very top el.style.paddingBottom = bottomPad + 'px'; } }; apply('bookCards'); apply('chapterCards'); apply('verseCards'); } catch (_) {} } populateTranslations(tab) { const translations = getVisibleTranslationCodesForTab(tab); const translationOptions = document.getElementById(`translationOptions${tab.id}`); // Safety check: if element doesn't exist, return early if (!translationOptions) { console.warn('[populateTranslations] Translation options element not found for tab:', tab.id); return; } // Ensure filter state exists on the tab (null = all, 'OT' or 'NT') if (!('bookFilter' in tab)) { tab.bookFilter = null; } // Build OT/NT filter buttons (shown only on book selection stage) const showCanon = !tab.book; // book not chosen yet => in book selection stage const filterButtonsHtml = showCanon ? `
` : ''; const translationButtonsHtml = translations.length ? translations.map(trans => ` `).join('') : `
No translations are enabled. Open Settings > Translation Visibility to turn translations back on.
`; translationOptions.innerHTML = filterButtonsHtml + translationButtonsHtml; // Add event listeners for translation buttons translationOptions.querySelectorAll('.translation-btn:not(.canon-btn)').forEach(btn => { btn.addEventListener('click', () => { const newTranslation = btn.dataset.translation; console.log('[TRANSLATION SWITCH] Button clicked - current:', tab.translation, 'new:', newTranslation, 'book:', tab.book, 'chapter:', tab.chapter); // Only reload if translation actually changed and we have a passage loaded if (tab.translation !== newTranslation && tab.book && tab.chapter) { console.log('[TRANSLATION SWITCH] Translation change detected - saving current annotations, loading new translation annotations'); // Close any open popups when changing translations try { if (typeof closeWordPopup === 'function') { closeWordPopup(); } } catch (e) { console.error('Error closing popups on translation change:', e); } const prevTrans = tab.translation; // CRITICAL FIX: Save current translation's annotations BEFORE switching // Each translation has separate annotations for the same chapter try { const currentPassageKey = getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation, tab.mode, !!tab.ppsync); if (currentPassageKey && strokeHistory[currentPassageKey] && strokeHistory[currentPassageKey].length > 0) { console.log('[TRANSLATION SWITCH] Saving', strokeHistory[currentPassageKey].length, 'strokes for current translation:', tab.translation, 'key:', currentPassageKey); saveAnnotations(tab.id); } } catch (e) { console.error('[TRANSLATION SWITCH] Error saving annotations before switch:', e); } // STEP 1: Clear canvas layers visually (strokeHistory is preserved per translation) // Each translation has its own passage key, so strokeHistory is separate try { const canvas = annotationCanvases[tab.id]; const overlay = annotationOverlayCanvases[tab.id]; const offscreen = annotationOffscreen[tab.id]; if (canvas) { const ctx = canvas.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); console.log('[TRANSLATION SWITCH] Cleared visible canvas for new translation'); } if (overlay) { const ctx = overlay.getContext('2d'); ctx.clearRect(0, 0, overlay.width, overlay.height); } if (offscreen) { const ctx = offscreen.getContext('2d'); const dpr = window.devicePixelRatio || 1; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, offscreen.width, offscreen.height); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, offscreen.width / dpr, offscreen.height / dpr); console.log('[TRANSLATION SWITCH] Cleared offscreen canvas for new translation'); } } catch (e) { console.error('[TRANSLATION SWITCH] Error clearing canvases:', e); } // STEP 2: Update translation (this changes the passage key) tab.translation = newTranslation; tab._translationChanged = true; tab._skipAnnotationClearInLoadPassage = true; // Don't clear annotations, just load new ones // STEP 3: Update UI this.populateTranslations(tab); // STEP 4: Load passage - this will load annotations for the new translation if (this.loadPassage) { this.loadPassage(tab.id).then(() => { // After loadPassage completes, load annotations for the new translation const newPassageKey = getCurrentPassageKey(); console.log('[TRANSLATION SWITCH] After loadPassage, loading annotations for new translation:', tab.translation, 'key:', newPassageKey); loadAnnotations(tab.id); redrawAllStrokes(tab.id); updateVisibleCanvas(tab.id); // Clear flags tab._translationChanged = false; tab._skipAnnotationClearInLoadPassage = false; }).catch(() => { // Even on error, ensure annotations are loaded loadAnnotations(tab.id); redrawAllStrokes(tab.id); updateVisibleCanvas(tab.id); tab._translationChanged = false; tab._skipAnnotationClearInLoadPassage = false; }); } } else { tab.translation = newTranslation; this.populateTranslations(tab); } }); }); // Add event listeners for canon filter buttons (only apply on book stage) const bookStage = document.getElementById(`bookStage${tab.id}`); translationOptions.querySelectorAll('.canon-btn').forEach(btn => { btn.addEventListener('click', () => { const canon = btn.getAttribute('data-canon'); // Toggle behavior: clicking selected filter turns it off (show all) if (tab.bookFilter === canon) { tab.bookFilter = null; } else { tab.bookFilter = canon; } // Re-render translations to update selected state this.populateTranslations(tab); // Only refresh books if on book stage if (showCanon) { this.populateBooks(tab); } }); }); } populateBooks(tab) { let books = Object.keys(bibleStructure); // Apply OT/NT filtering if active if (tab.bookFilter === 'OT') { books = books.filter(b => OLD_TESTAMENT_BOOKS.includes(b)); } else if (tab.bookFilter === 'NT') { books = books.filter(b => NEW_TESTAMENT_BOOKS.includes(b)); } // Keep order as in bibleStructure; filtering preserves order const bookCards = document.getElementById(`bookCards${tab.id}`); const backButtonArea = document.getElementById(`backButtonArea${tab.id}`); // Safety check: if elements don't exist, return early if (!bookCards || !backButtonArea) { console.warn('[populateBooks] Required elements not found for tab:', tab.id); return; } // Clear back button when in book selection backButtonArea.innerHTML = ''; // Reset list and render all books (66 items; OK to render at once) bookCards.innerHTML = books.map(book => `
${book}
`).join(''); // Reset scroll to top on re-render requestAnimationFrame(() => { try { const selectionContentEl = document.getElementById(`selectionContent${tab.id}`); if (selectionContentEl) selectionContentEl.scrollTop = 0; const bookCardsEl = document.getElementById(`bookCards${tab.id}`); if (bookCardsEl) bookCardsEl.scrollTop = 0; } catch (_) {} }); // Delegate clicks to container for fewer listeners if (!bookCards._delegated) { bookCards.addEventListener('click', (ev) => { const card = ev.target.closest && ev.target.closest('.selection-card'); if (!card || !bookCards.contains(card)) return; const selectedBook = card.dataset.book; const tabId = card.dataset.tabId; const t = this.tabs.find(tt => String(tt.id) === String(tabId)) || tab; t.book = selectedBook; t.chapter = null; t.verse = null; t.verseEnd = null; t.isSelectingVerses = false; this.populateSelectors(t); }); bookCards._delegated = true; } } populateChapters(tab) { if (!tab.book) return; const chapters = Array.from({length: bibleStructure[tab.book].length}, (_, i) => i + 1); const chapterCards = document.getElementById(`chapterCards${tab.id}`); const selectionContent = document.getElementById(`selectionContent${tab.id}`); const backButtonArea = document.getElementById(`backButtonArea${tab.id}`); // Safety check: if elements don't exist, return early if (!chapterCards || !backButtonArea) { console.warn('[populateChapters] Required elements not found for tab:', tab.id); return; } backButtonArea.innerHTML = ` `; // Reset list and render all chapters (<= 150; OK to render at once) chapterCards.innerHTML = chapters.map(chapter => `
${chapter}
`).join(''); // Reset scroll to top on re-render requestAnimationFrame(() => { try { const selectionContentEl = document.getElementById(`selectionContent${tab.id}`); if (selectionContentEl) selectionContentEl.scrollTop = 0; const chapterCardsEl = document.getElementById(`chapterCards${tab.id}`); if (chapterCardsEl) chapterCardsEl.scrollTop = 0; } catch (_) {} }); // Delegate chapter interactions (tap vs hold) to container to reduce listeners if (!chapterCards._delegated) { chapterCards.addEventListener('touchstart', (e) => { const card = e.target.closest && e.target.closest('.selection-card'); if (!card || !chapterCards.contains(card)) return; if (e.touches && e.touches.length >= 2) return; if (typeof globalMultiTouchActive !== 'undefined' && globalMultiTouchActive) return; if (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active) return; const touch = e.touches[0]; chapterCards._holdX = touch.clientX; chapterCards._holdY = touch.clientY; chapterCards._isHolding = false; chapterCards._holdTimer && clearTimeout(chapterCards._holdTimer); const HOLD_MS = 500, MOVE_THRESHOLD = 10; chapterCards._moveThreshold = MOVE_THRESHOLD; chapterCards._activeCard = card; chapterCards._holdTimer = setTimeout(() => { if (typeof globalMultiTouchActive !== 'undefined' && globalMultiTouchActive) return; if (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active) return; chapterCards._isHolding = true; chapterCards._holdTimer = null; const selectedChapter = parseInt(card.dataset.chapter); tab.chapter = selectedChapter; tab.verse = null; tab.verseEnd = null; tab.isSelectingVerses = true; // Increase cooldown to prevent the trailing tap/click from selecting a verse right after hold tab.verseSelectionCooldown = Date.now() + 1200; this.populateSelectors(tab); // Temporarily disable verse grid interactions to avoid visual press/highlight from the finishing touch try { const vc = document.getElementById(`verseCards${tab.id}`); if (vc) { vc.style.pointerEvents = 'none'; const reenableMs = Math.max(250, Math.min(800, (tab.verseSelectionCooldown || 0) - Date.now())); setTimeout(() => { try { vc.style.pointerEvents = ''; } catch(_) {} }, isNaN(reenableMs) ? 400 : reenableMs); } } catch (_) {} }, HOLD_MS); }, { passive: true }); chapterCards.addEventListener('touchmove', (e) => { if (!chapterCards._holdTimer) return; if (e.touches && e.touches.length >= 2) { clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; return; } if (typeof globalMultiTouchActive !== 'undefined' && globalMultiTouchActive) { clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; return; } if (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active) { clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; return; } const touch = e.touches[0]; const dx = Math.abs(touch.clientX - (chapterCards._holdX || 0)); const dy = Math.abs(touch.clientY - (chapterCards._holdY || 0)); if (dx > (chapterCards._moveThreshold || 10) || dy > (chapterCards._moveThreshold || 10)) { clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; } }, { passive: true }); chapterCards.addEventListener('touchend', (e) => { const card = e.target.closest && e.target.closest('.selection-card'); if (!card || !chapterCards.contains(card)) return; if (typeof globalMultiTouchActive !== 'undefined' && globalMultiTouchActive) { chapterCards._isHolding = false; chapterCards._holdTimer && clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; return; } if (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active) { chapterCards._isHolding = false; chapterCards._holdTimer && clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; return; } if (chapterCards._isHolding) { chapterCards._isHolding = false; return; } if (chapterCards._holdTimer) { const MOVE_THRESHOLD = chapterCards._moveThreshold || 10; const touch = e.changedTouches[0]; const dx = Math.abs(touch.clientX - (chapterCards._holdX || 0)); const dy = Math.abs(touch.clientY - (chapterCards._holdY || 0)); clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; if (dx <= MOVE_THRESHOLD && dy <= MOVE_THRESHOLD) { const selectedChapter = parseInt(card.dataset.chapter); // New behavior: tap chapter -> open verse selector tab.chapter = selectedChapter; tab.verse = null; tab.verseEnd = null; tab.isSelectingVerses = true; this.populateSelectors(tab); } } chapterCards._isHolding = false; }, { passive: true }); chapterCards.addEventListener('mousedown', (e) => { const card = e.target.closest && e.target.closest('.selection-card'); if (!card || !chapterCards.contains(card)) return; if (e.touches) return; if (typeof globalMultiTouchActive !== 'undefined' && globalMultiTouchActive) return; if (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active) return; chapterCards._isHolding = false; chapterCards._holdX = e.clientX; chapterCards._holdY = e.clientY; chapterCards._holdTimer && clearTimeout(chapterCards._holdTimer); const HOLD_MS = 500; chapterCards._holdTimer = setTimeout(() => { if (typeof globalMultiTouchActive !== 'undefined' && globalMultiTouchActive) return; if (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active) return; chapterCards._isHolding = true; chapterCards._holdTimer = null; const selectedChapter = parseInt(card.dataset.chapter); tab.chapter = selectedChapter; tab.verse = null; tab.verseEnd = null; tab.isSelectingVerses = true; // Increase cooldown to prevent the trailing click from selecting a verse right after hold tab.verseSelectionCooldown = Date.now() + 1200; this.populateSelectors(tab); // Temporarily disable verse grid interactions to avoid visual press/highlight from the finishing click try { const vc = document.getElementById(`verseCards${tab.id}`); if (vc) { vc.style.pointerEvents = 'none'; const reenableMs = Math.max(250, Math.min(800, (tab.verseSelectionCooldown || 0) - Date.now())); setTimeout(() => { try { vc.style.pointerEvents = ''; } catch(_) {} }, isNaN(reenableMs) ? 400 : reenableMs); } } catch (_) {} }, HOLD_MS); }); chapterCards.addEventListener('mousemove', (e) => { if (!chapterCards._holdTimer) return; if (typeof globalMultiTouchActive !== 'undefined' && globalMultiTouchActive) { clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; return; } if (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active) { clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; return; } const dx = Math.abs(e.clientX - (chapterCards._holdX || 0)); const dy = Math.abs(e.clientY - (chapterCards._holdY || 0)); const MOVE_THRESHOLD = chapterCards._moveThreshold || 10; if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) { clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; } }); chapterCards.addEventListener('mouseup', (e) => { const card = e.target.closest && e.target.closest('.selection-card'); if (!card || !chapterCards.contains(card)) return; if (e.touches) return; if (typeof globalMultiTouchActive !== 'undefined' && globalMultiTouchActive) return; if (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active) return; if (chapterCards._isHolding) { chapterCards._isHolding = false; return; } if (chapterCards._holdTimer) { const MOVE_THRESHOLD = chapterCards._moveThreshold || 10; const dx = Math.abs(e.clientX - (chapterCards._holdX || 0)); const dy = Math.abs(e.clientY - (chapterCards._holdY || 0)); clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; if (dx <= MOVE_THRESHOLD && dy <= MOVE_THRESHOLD) { const selectedChapter = parseInt(card.dataset.chapter); // New behavior: tap chapter -> open verse selector tab.chapter = selectedChapter; tab.verse = null; tab.verseEnd = null; tab.isSelectingVerses = true; this.populateSelectors(tab); } } chapterCards._isHolding = false; }); chapterCards.addEventListener('mouseleave', () => { if (chapterCards._holdTimer) { clearTimeout(chapterCards._holdTimer); chapterCards._holdTimer = null; } chapterCards._isHolding = false; }); chapterCards._delegated = true; } // Add back button event listener backButtonArea.querySelector('.header-back-btn').addEventListener('click', () => { tab.book = ''; tab.chapter = null; tab.verse = null; tab.verseEnd = null; tab.isSelectingVerses = false; this.populateSelectors(tab); }); } populateVerses(tab) { if (!tab.book || !tab.chapter) return; const verseCount = bibleStructure[tab.book][tab.chapter - 1]; const versesAll = Array.from({length: verseCount}, (_, i) => i + 1); const tcode = String(tab.translation || '').toUpperCase(); const missingSet = this.missingVersesByTranslation[tcode] || null; const verses = missingSet ? versesAll.filter(v => !missingSet.has(`${tab.book}|${tab.chapter}|${v}`)) : versesAll; const verseCards = document.getElementById(`verseCards${tab.id}`); const backButtonArea = document.getElementById(`backButtonArea${tab.id}`); // Safety check: if elements don't exist, return early if (!verseCards || !backButtonArea) { console.warn('[populateVerses] Required elements not found for tab:', tab.id); return; } backButtonArea.innerHTML = ` `; verseCards.innerHTML = verses.map(verse => { // Only show selection if we have valid verse selection AND we're not in initial verse selection state const hasValidSelection = tab.verse && tab.verseEnd && tab.verse !== null && tab.verseEnd !== null && !tab.isSelectingVerses; const isSelected = hasValidSelection && verse >= tab.verse && verse <= tab.verseEnd; const isStart = hasValidSelection && verse === tab.verse; const isEnd = hasValidSelection && verse === tab.verseEnd; return `
${verse}
`; }).join(''); // Ensure no verse appears pre-selected or visually pressed when entering via hold // Clear classes and briefly remove focus/active states if (tab.isSelectingVerses || (tab.verseSelectionCooldown && Date.now() < tab.verseSelectionCooldown)) { try { verseCards.querySelectorAll('.selection-card').forEach(card => { card.classList.remove('selected', 'range-start', 'range-end'); }); // Remove any lingering :focus styles const activeEl = document.activeElement; if (activeEl && verseCards.contains(activeEl)) { try { activeEl.blur(); } catch(_) {} } // Briefly disable pointer events at grid level to swallow trailing events verseCards.style.pointerEvents = 'none'; const delay = Math.max(200, Math.min(600, (tab.verseSelectionCooldown || 0) - Date.now())); setTimeout(() => { try { verseCards.style.pointerEvents = ''; } catch(_) {} }, isNaN(delay) ? 300 : delay); } catch (_) {} } // Reset scroll to top on re-render requestAnimationFrame(() => { try { const selectionContentEl = document.getElementById(`selectionContent${tab.id}`); if (selectionContentEl) selectionContentEl.scrollTop = 0; const verseCardsEl = document.getElementById(`verseCards${tab.id}`); if (verseCardsEl) verseCardsEl.scrollTop = 0; } catch (_) {} }); // Add back button event listener backButtonArea.querySelector('.header-back-btn').addEventListener('click', () => { tab.chapter = null; tab.verse = null; tab.verseEnd = null; tab.isSelectingVerses = false; this.populateSelectors(tab); }); // Simple verse tap: jump to verse (no range selection) if (!verseCards._simpleClickDelegated) { verseCards.addEventListener('click', (e) => { const card = e.target && e.target.closest && e.target.closest('.selection-card'); if (!card || !verseCards.contains(card)) return; const v = parseInt(card.getAttribute('data-verse') || '0', 10); if (isNaN(v) || v <= 0) return; try { e.preventDefault(); e.stopPropagation(); } catch (_) {} tab._scrollToVerse = v; // Set the verse to the clicked verse so the passage key differs and load is forced tab.verse = v; tab.verseEnd = null; tab.isSelectingVerses = false; this.loadPassage(tab.id); this.renderTabs(); }); // Touchend handler for TVs that dispatch touch without click verseCards.addEventListener('touchend', (e) => { const card = e.target && e.target.closest && e.target.closest('.selection-card'); if (!card || !verseCards.contains(card)) return; const v = parseInt(card.getAttribute('data-verse') || '0', 10); if (isNaN(v) || v <= 0) return; try { e.preventDefault(); e.stopPropagation(); } catch (_) {} tab._scrollToVerse = v; tab.verse = 1; tab.verseEnd = null; tab.isSelectingVerses = false; this.loadPassage(tab.id); this.renderTabs(); }, { passive: false }); verseCards._simpleClickDelegated = true; } } setupVerseRangeSelection(tab) { const verseCards = document.getElementById(`verseCards${tab.id}`); let isSelecting = false; let startVerse = null; let selectionHoldTimer = null; let initialTouchX = 0; let initialTouchY = 0; const HOLD_MS = 140; // slightly longer hold to avoid accidental select while starting 2-finger scroll const MOVE_THRESHOLD = 12; // px const committedStart = tab.verse; const committedEnd = tab.verseEnd; if (typeof window.suppressMouseUntil === 'undefined') { window.suppressMouseUntil = 0; } const loadPassageAndUpdateUI = () => { this.loadPassage(tab.id); this.renderTabs(); }; const startSelection = (e) => { const card = e.target.closest('.selection-card'); if (!card) return; isSelecting = true; startVerse = parseInt(card.dataset.verse); this.updateVerseSelection(tab, startVerse, startVerse); }; const continueSelection = (e) => { if (!isSelecting) return; const card = e.target.closest('.selection-card'); if (!card) return; const currentVerse = parseInt(card.dataset.verse); const start = Math.min(startVerse, currentVerse); const end = Math.max(startVerse, currentVerse); this.updateVerseSelection(tab, start, end); }; const endSelection = () => { if (!isSelecting) return; isSelecting = false; loadPassageAndUpdateUI(); }; // Single tap/click handler verseCards.addEventListener('click', (e) => { // Suppress clicks during/after multi-touch to avoid accidental selection if (typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) return; if (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active) return; if (isSelecting) return; // Don't handle click if we're in the middle of a range selection // Prevent immediate verse selection after UI loads under finger if (tab.verseSelectionCooldown && Date.now() < tab.verseSelectionCooldown) return; const card = e.target.closest('.selection-card'); if (!card) return; // New behavior: tap a verse loads the chapter and scrolls to that verse (force load by changing key) const verse = parseInt(card.dataset.verse); tab._scrollToVerse = verse; tab.verse = verse; // force a different passage key to trigger load tab.verseEnd = null; tab.isSelectingVerses = false; loadPassageAndUpdateUI(); }); // Touch events for range selection verseCards.addEventListener('touchstart', (e) => { // Ignore multi-touch; allow two-finger gesture to take over for scrolling if (e.touches && e.touches.length >= 2) { // cancel any pending selection if (selectionHoldTimer) { clearTimeout(selectionHoldTimer); selectionHoldTimer = null; } if (isSelecting) { isSelecting = false; this.updateVerseSelection(tab, committedStart, committedEnd); } return; } // Prevent immediate verse selection after UI loads under finger if (tab.verseSelectionCooldown && Date.now() < tab.verseSelectionCooldown) return; // Set mouse suppression to ignore synthetic mouse events after touch window.suppressMouseUntil = Date.now() + 800; const t = e.touches[0]; initialTouchX = t.clientX; initialTouchY = t.clientY; // Delay starting selection slightly to avoid accidental start when user intends 2-finger if (selectionHoldTimer) clearTimeout(selectionHoldTimer); selectionHoldTimer = setTimeout(() => { // Do not start selection if multi-touch/2-finger is active if (globalMultiTouchActive || (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active)) { selectionHoldTimer = null; return; } selectionHoldTimer = null; startSelection(e); }, HOLD_MS); }, { passive: true }); verseCards.addEventListener('touchmove', (e) => { // If two or more fingers, DO NOT interfere with scrolling; cancel selection/pending if (e.touches && e.touches.length >= 2) { if (selectionHoldTimer) { clearTimeout(selectionHoldTimer); selectionHoldTimer = null; } if (isSelecting) { isSelecting = false; this.updateVerseSelection(tab, committedStart, committedEnd); } return; } const t = e.touches[0]; const dx = Math.abs(t.clientX - initialTouchX); const dy = Math.abs(t.clientY - initialTouchY); // If user moves before selection began, treat as non-selection (allow gesture to be ignored) if (!isSelecting) { if (selectionHoldTimer && (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD)) { clearTimeout(selectionHoldTimer); selectionHoldTimer = null; } return; // don't prevent default; single-finger scroll is blocked elsewhere anyway } // If actively selecting, prevent default and continue selection under finger e.preventDefault(); const element = document.elementFromPoint(t.clientX, t.clientY); continueSelection({ target: element }); }, { passive: false }); verseCards.addEventListener('touchend', (e) => { if (selectionHoldTimer) { clearTimeout(selectionHoldTimer); selectionHoldTimer = null; } if (!globalMultiTouchActive && !(typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active)) { endSelection(); } else { // If gesture ended in multi-touch context, cancel selection and restore isSelecting = false; this.updateVerseSelection(tab, committedStart, committedEnd); } // Extend mouse suppression slightly after touch ends window.suppressMouseUntil = Math.max(window.suppressMouseUntil, Date.now() + 400); }); // Mouse events for range selection verseCards.addEventListener('mousedown', (e) => { if (globalMultiTouchActive || (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active)) return; if (Date.now() < (window.suppressMouseUntil || 0)) return; // Prevent immediate verse selection after UI loads under cursor if (tab.verseSelectionCooldown && Date.now() < tab.verseSelectionCooldown) return; startSelection(e); }); verseCards.addEventListener('mousemove', (e) => { if (globalMultiTouchActive || (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active)) return; if (Date.now() < (window.suppressMouseUntil || 0)) return; continueSelection(e); }); verseCards.addEventListener('mouseup', (e) => { if (globalMultiTouchActive || (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active)) return; if (Date.now() < (window.suppressMouseUntil || 0)) return; endSelection(e); }); verseCards.addEventListener('mouseleave', endSelection); } updateVerseSelection(tab, startVerse, endVerse) { const verseCards = document.getElementById(`verseCards${tab.id}`); verseCards.querySelectorAll('.selection-card').forEach(card => { const verse = parseInt(card.dataset.verse); const isSelected = verse >= startVerse && verse <= endVerse; const isStart = verse === startVerse; const isEnd = verse === endVerse; card.classList.toggle('selected', isSelected); card.classList.toggle('range-start', isStart); card.classList.toggle('range-end', isEnd); }); tab.verse = startVerse; tab.verseEnd = endVerse; tab.isSelectingVerses = false; // Clear the flag when verses are actually selected // Don't update the chapter header here - it will be updated when the passage is loaded } updateStageVisibility(tab) { const bookStage = document.getElementById(`bookStage${tab.id}`); const chapterStage = document.getElementById(`chapterStage${tab.id}`); const verseStage = document.getElementById(`verseStage${tab.id}`); // Show/hide stages based on current selection bookStage.classList.toggle('hidden', !!tab.book); chapterStage.classList.toggle('hidden', !tab.book || !!tab.chapter); verseStage.classList.toggle('hidden', !tab.book || !tab.chapter); } async loadPassage(tabId) { const tab = this.tabs.find(t => t.id === tabId); if (!tab) return; // CRITICAL FIX: Prevent race conditions - invalidate any pending load for this tab. // Each load gets a unique token; an in-flight load bails as soon as the token changes. // (A shared boolean was previously reset to false before the stale load checked it, // which defeated cancellation and let two loads race.) const myLoadToken = (tab._loadToken || 0) + 1; tab._loadToken = myLoadToken; // CRITICAL FIX: Validate we have required data before attempting to load if (!tab.book || !tab.chapter) { console.warn('[loadPassage] Cannot load passage: missing book or chapter', { book: tab.book, chapter: tab.chapter }); return; } // Normalize the passage fields so EVERY caller (selection UI without a verse pick, remote, // OSC, tapped references) produces a valid render. verse=1 + verseEnd=null is // the canonical "whole chapter" state — a missing/invalid verse must never reach the // renderer, where it silently drops the chapter header and can break the text load. tab.chapter = parseInt(tab.chapter, 10) || 1; tab.verse = parseInt(tab.verse, 10) || 1; if (tab.verseEnd != null) { tab.verseEnd = parseInt(tab.verseEnd, 10) || null; if (tab.verseEnd !== null && tab.verseEnd < tab.verse) tab.verseEnd = null; } else { tab.verseEnd = null; } // If we're reloading the same passage with a verse jump and we already have saved text, // render it immediately and attempt scroll while the full load runs. try { if (tab._scrollToVerse && tab.savedText) { const bibleTextFast = document.getElementById(`bibleText${tabId}`); if (bibleTextFast) { bibleTextFast.innerHTML = tab.savedText; // CRITICAL FIX: Keep header visible but clear title text during fast-path loading // This prevents loading text flash while keeping header steady const chapterHeaderFast = document.getElementById(`chapterHeader${tabId}`); if (chapterHeaderFast) { // Keep header visible, just clear the title text const titleEl = chapterHeaderFast.querySelector('.chapter-title'); if (titleEl) { titleEl.textContent = ''; } } // Attempt immediate scroll requestAnimationFrame(() => { requestAnimationFrame(() => { try { if (typeof this.autoScrollToSelectedVerse === 'function') { this.autoScrollToSelectedVerse(tabId); } } catch (_) {} }); }); } } } catch (_) {} // Store the promise so we can cancel it if needed const loadPromise = this._loadPassageInternal(tabId, myLoadToken); tab._loadPassagePromise = loadPromise; try { await loadPromise; } catch (error) { // If load was superseded by a newer load, don't treat it as an error if (tab._loadToken === myLoadToken) { console.error('[loadPassage] Fatal error loading passage:', error); try { const bibleText = document.getElementById(`bibleText${tabId}`); if (bibleText) { bibleText.innerHTML = '
No text available (error loading passage).
'; } } catch (_) {} } } finally { // Clear the promise when done if (tab._loadPassagePromise === loadPromise) { tab._loadPassagePromise = null; } } } async _loadPassageInternal(tabId, loadToken) { console.log(`[loadPassage] _loadPassageInternal called for tab ${tabId}`); const tab = this.tabs.find(t => t.id === tabId); if (!tab) { console.log(`[loadPassage] Tab not found: ${tabId}`); return; } // Check if this load was cancelled if (tab._loadToken !== loadToken) { console.log(`[loadPassage] Load cancelled for tab ${tabId}`); return; } console.log(`[loadPassage] Tab found, proceeding with load. verse=${tab.verse}, verseEnd=${tab.verseEnd}, _scrollToVerse=${tab._scrollToVerse}`); // CRITICAL FIX: Store the previous translation BEFORE it might be used // Use stored previous translation if available, otherwise use current (for first load) // This MUST be checked BEFORE we use tab.translation anywhere else const previousTranslationForComparison = tab._previousTranslationBeforeLoad || tab._lastLoadedTranslation || tab.translation; console.log('[DEBUG] *** loadPassage translation detection - _previousTranslationBeforeLoad:', tab._previousTranslationBeforeLoad, '_lastLoadedTranslation:', tab._lastLoadedTranslation, 'current translation:', tab.translation, 'using:', previousTranslationForComparison); // Store previous passage state for undo functionality const previousPassage = { book: tab.book, chapter: tab.chapter, verse: tab.verse, verseEnd: tab.verseEnd, translation: previousTranslationForComparison, // Use stored previous translation, not current loaded: tab.loaded, savedText: tab.savedText, savedHeader: tab.savedHeader }; // Store new passage state for redo functionality const newPassage = { book: tab.book, chapter: tab.chapter, verse: tab.verse, verseEnd: tab.verseEnd, translation: tab.translation, loaded: true }; // Create action for passage load const action = createAction(ACTION_TYPES.PASSAGE_LOAD, { tabId: tabId, previousPassage: previousPassage, newPassage: newPassage }); pushAction(action); // The passage is part of the OSC information line, so a chapter change refreshes it too. try { scheduleCompanionActiveTabFeedback(); } catch (_) {} // CRITICAL FIX: Annotations are chapter-based, per translation // Only clear annotations if we're loading a DIFFERENT chapter/book (not just different translation) const currentPassageKey = getCurrentPassageKey(); const newPassageKey = getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation, tab.mode, !!tab.ppsync); // Extract book and chapter from keys to compare (ignore translation) const getBookChapterFromKey = (key) => { // 'ppsync-annotations-...' is the sync tab's own store; same shape, so the same parse. if (!key || !(key.startsWith('bible-annotations-') || key.startsWith('ppsync-annotations-'))) return null; const parts = key.split('-'); if (parts.length >= 4) { // Format: -annotations-${book}-${chapter}-${translation} return { book: parts[2], chapter: parts[3] }; } return null; }; const currentBC = getBookChapterFromKey(currentPassageKey); const newBC = getBookChapterFromKey(newPassageKey); const chapterChanged = !!(currentBC && newBC && (currentBC.book !== newBC.book || currentBC.chapter !== newBC.chapter)); console.log('[DEBUG] *** loadPassage - current key:', currentPassageKey, 'new key:', newPassageKey, 'chapterChanged:', chapterChanged, 'skipClear:', !!tab._skipAnnotationClearInLoadPassage); // CRITICAL FIX: Only clear annotations if chapter/book changed, NOT if just translation changed // When translation changes on same chapter, we load different annotations (different passage key) // but don't clear strokeHistory - each translation has its own storage const shouldClearAnnotations = tab._skipAnnotationClearInLoadPassage ? false : ( tab._forceAnnotationClear || chapterChanged || (currentPassageKey && newPassageKey && currentPassageKey !== newPassageKey && !tab._translationChanged) ); console.log('[DEBUG] *** loadPassage annotation check - chapterChanged:', chapterChanged, 'shouldClear:', shouldClearAnnotations); // Clear the flag after checking if (tab._skipAnnotationClearInLoadPassage) { tab._skipAnnotationClearInLoadPassage = false; } if (shouldClearAnnotations) { console.log('[DEBUG] *** CLEARING ANNOTATIONS - Different passage or translation change detected'); // CRITICAL FIX: Clear canvas IMMEDIATELY when translation changes try { const canvas = annotationCanvases[tabId]; const overlay = annotationOverlayCanvases[tabId]; const offscreen = annotationOffscreen[tabId]; if (canvas) { const ctx = canvas.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); console.log('[loadPassage] Cleared visible canvas immediately'); } if (overlay) { const ctx = overlay.getContext('2d'); ctx.clearRect(0, 0, overlay.width, overlay.height); } if (offscreen) { const ctx = offscreen.getContext('2d'); const dpr = window.devicePixelRatio || 1; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, offscreen.width, offscreen.height); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, offscreen.width / dpr, offscreen.height / dpr); console.log('[loadPassage] Cleared offscreen canvas immediately'); } } catch (e) { console.error('[loadPassage] Error clearing canvases:', e); } // Clear current annotations before loading new passage forceClearAnnotationCanvases(tabId); resetAnnotationCanvases(tabId); // Preserve stroke history in memory; only clear the visuals redrawAllStrokes(tabId); updateVisibleCanvas(tabId); scheduleAnnotationRefresh(tabId); } else if (tab._translationSwitchInProgress) { // CRITICAL FIX: Even if passage keys match, if translation switch is in progress, clear annotations console.log('[DEBUG] *** CLEARING ANNOTATIONS - Translation switch in progress'); forceClearAnnotationCanvases(tabId); // Clear the flag tab._translationSwitchInProgress = false; scheduleAnnotationRefresh(tabId); } else { console.log('[DEBUG] *** PRESERVING ANNOTATIONS - Same passage, not clearing'); // CRITICAL FIX: Even if passage key is the same, if _scrollToVerse is set, we need to ensure display and scroll if (tab._scrollToVerse) { console.log(`[loadPassage] Same passage key but _scrollToVerse=${tab._scrollToVerse} is set - will force display and scroll`); // Don't skip - continue to load and display to ensure scrolling works } } tab._forceAnnotationClear = false; tab._prevAnnotationKeyForClear = null; tab._previousTranslation = null; // Determine translation code early for fast-path const tcode = String(tab.translation || '').toUpperCase(); // FAST-PATH: if a verse jump is requested on the same passage key, immediately load and render the chapter, // then scroll, instead of waiting for the main async flow. This avoids blank screen until tab switch. if (tab._scrollToVerse) { try { const fastVerses = await this.loadLocalPassageFromTranslations(tcode, tab.book, tab.chapter, 1, null); if (fastVerses && Array.isArray(fastVerses) && fastVerses.length > 0) { const availableVerses = fastVerses.map(v => v.verse).sort((a, b) => a - b); const scrollTarget = availableVerses.includes(Number(tab._scrollToVerse)) ? Number(tab._scrollToVerse) : availableVerses.reduce((closest, v) => { const distClosest = Math.abs(closest - tab._scrollToVerse); const distV = Math.abs(v - tab._scrollToVerse); return distV < distClosest ? v : closest; }, availableVerses[0]); tab.verse = 1; tab.verseEnd = null; tab._scrollToVerse = scrollTarget; const bibleTextFast = document.getElementById(`bibleText${tabId}`); const chapterHeaderFast = document.getElementById(`chapterHeader${tabId}`); const versesWithTextFast = fastVerses.filter(v => v && v.text && String(v.text).trim().length > 0); const finalFast = versesWithTextFast.length > 0 ? versesWithTextFast : fastVerses; if (bibleTextFast) { this.displayPassage(tab, finalFast, bibleTextFast, chapterHeaderFast); // Attempt immediate scroll with retries let retry = 0; const maxRetry = 10; const attemptScroll = () => { try { if (typeof this.autoScrollToSelectedVerse === 'function') { this.autoScrollToSelectedVerse(tabId); } const targetEl = document.getElementById(`v-${tab.id}-${tab._scrollToVerse}`); if (!targetEl && retry < maxRetry) { retry++; requestAnimationFrame(attemptScroll); } } catch (_) { if (retry < maxRetry) { retry++; setTimeout(attemptScroll, 50); } } }; requestAnimationFrame(() => requestAnimationFrame(attemptScroll)); } } } catch (e) { console.error('[loadPassage] fast-path render failed:', e); } } // Hide the selector container const selectorContainer = document.getElementById(`selectorContainer${tabId}`); if (selectorContainer) selectorContainer.style.display = 'none'; // Ensure reading area becomes visible when a passage loads (so selector height doesn't stack) const readingAreaEl = document.getElementById(`readingArea${tabId}`); if (readingAreaEl) readingAreaEl.style.display = 'block'; const bibleText = document.getElementById(`bibleText${tabId}`); const chapterHeader = document.getElementById(`chapterHeader${tabId}`); // CRITICAL FIX: Keep header visible but clear text content to prevent loading text flash // The header stays steady, only the text content changes when passage loads if (chapterHeader) { // Keep header visible, but clear the title text to prevent flashing old text const titleEl = chapterHeader.querySelector('.chapter-title'); if (titleEl) { titleEl.textContent = ''; } } // EARLY SMOOTHING: If an auto-jump is pending (e.g., translation change after scroll), keep text transparent until positioned try { if (tab._scrollToVerse && bibleText) { bibleText.style.opacity = '0'; bibleText.style.transition = 'none'; try { bibleText.style.willChange = 'opacity'; } catch(_) {} } } catch (_) {} console.log(`[loadPassage] Before passage string, tab.verse=${tab.verse}, tab.verseEnd=${tab.verseEnd}, tab._scrollToVerse=${tab._scrollToVerse}`); try { let passage; if (tab.verseEnd === null) { // Whole chapter passage = `${tab.book} ${tab.chapter}`; } else if (tab.verseEnd && tab.verseEnd !== tab.verse) { passage = `${tab.book} ${tab.chapter}:${tab.verse}-${tab.verseEnd}`; } else { passage = `${tab.book} ${tab.chapter}:${tab.verse}`; } console.log(`[loadPassage] Passage string: ${passage}, about to enter try block`); console.log(`[loadPassage] FORCE CHECK: tab._scrollToVerse=${tab._scrollToVerse}, tab.verse=${tab.verse}`); console.log(`[loadPassage] ? INSIDE TRY BLOCK - Starting try block, tab.verse=${tab.verse}, tab.verseEnd=${tab.verseEnd}, tab._scrollToVerse=${tab._scrollToVerse}`); let text = ''; let versesArr = null; // Unified local loading for offline usage console.log(`[loadPassage] Translation code: ${tcode}`); try { // CRITICAL FIX: Check if load was cancelled before proceeding if (tab._loadToken !== loadToken) { console.log(`[loadPassage] Load cancelled in inner try block`); return; } const requestedVerseStart = tab.verse !== null ? Number(tab.verse) : null; const requestedVerseEnd = tab.verseEnd !== null ? Number(tab.verseEnd) : null; // CRITICAL FIX: Check if user clicked a specific verse card (indicated by _scrollToVerse being set and different from verse) const scrollToVerse = tab._scrollToVerse !== null && tab._scrollToVerse !== undefined ? Number(tab._scrollToVerse) : null; console.log(`[loadPassage] DEBUG: verse=${tab.verse}, verseEnd=${tab.verseEnd}, _scrollToVerse=${tab._scrollToVerse}, scrollToVerse=${scrollToVerse}, requestedVerseStart=${requestedVerseStart}, requestedVerseEnd=${requestedVerseEnd}`); const isVerseCardClick = scrollToVerse !== null && scrollToVerse > 0 && (requestedVerseStart === null || requestedVerseStart === 1) && requestedVerseEnd === null; console.log(`[loadPassage] DEBUG: isVerseCardClick=${isVerseCardClick}`); // CRITICAL FIX: Only check verse existence if we're requesting a specific verse (not verse 1 with null end, which means whole chapter) const isSpecificVerseRequest = requestedVerseStart !== null && requestedVerseStart > 0 && (requestedVerseEnd !== null || requestedVerseStart > 1); // EMERGENCY FAST-PATH: if a verse card was clicked, immediately load the whole chapter and scroll, // bypassing downstream logic that may abort on same-passage keys. if (isVerseCardClick) { try { const allVerses = await this.loadLocalPassageFromTranslations(tcode, tab.book, tab.chapter, 1, null); if (allVerses && Array.isArray(allVerses) && allVerses.length > 0) { const availableVerses = allVerses.map(v => v.verse).sort((a, b) => a - b); const verseExists = availableVerses.includes(scrollToVerse); tab.verse = 1; tab.verseEnd = null; if (!verseExists) { const closestVerse = availableVerses.reduce((closest, v) => { const distClosest = Math.abs(closest - scrollToVerse); const distV = Math.abs(v - scrollToVerse); return distV < distClosest ? v : closest; }, availableVerses[0]); tab._scrollToVerse = closestVerse || scrollToVerse; console.log(`[loadPassage] FAST-PATH: requested ${scrollToVerse} missing, using closest ${tab._scrollToVerse}`); } else { tab._scrollToVerse = scrollToVerse; console.log(`[loadPassage] FAST-PATH: verse ${scrollToVerse} exists, will scroll to it`); } const versesWithText = allVerses.filter(v => v && v.text && String(v.text).trim().length > 0); const finalVerses = versesWithText.length > 0 ? versesWithText : allVerses; const bibleTextEl = document.getElementById(`bibleText${tab.id}`); const chapterHeaderEl = document.getElementById(`chapterHeader${tab.id}`); this.displayPassage(tab, finalVerses, bibleTextEl, chapterHeaderEl); // Retry scrolling several times to avoid race conditions let retryCount = 0; const maxRetries = 10; const attemptScroll = () => { try { if (typeof this.autoScrollToSelectedVerse === 'function') { this.autoScrollToSelectedVerse(tab.id); } const targetEl = document.getElementById(`v-${tab.id}-${tab._scrollToVerse}`); if (!targetEl && retryCount < maxRetries) { retryCount++; requestAnimationFrame(attemptScroll); } } catch (e) { if (retryCount < maxRetries) { retryCount++; setTimeout(attemptScroll, 50); } } }; requestAnimationFrame(() => requestAnimationFrame(attemptScroll)); tab.loaded = true; this.renderTabs(); return; // Skip the rest; we've already displayed and initiated scroll. } else { console.error(`[loadPassage] FAST-PATH failed: no verses for ${tab.book} ${tab.chapter} ${tcode}`); } } catch (e) { console.error('[loadPassage] FAST-PATH error:', e); } } // CRITICAL FIX: Always ensure we have valid verses to display // If requesting a specific verse (even if verseEnd is null), first check if it exists let verses = null; // CRITICAL FIX: Handle verse card clicks (e.g., clicking verse 37 when verse doesn't exist) if (isVerseCardClick) { console.log(`[loadPassage] Verse card click detected: verse ${scrollToVerse}, loading whole chapter`); // User clicked a specific verse card - load whole chapter and check if verse exists const allVerses = await this.loadLocalPassageFromTranslations(tcode, tab.book, tab.chapter, 1, null); if (allVerses && Array.isArray(allVerses) && allVerses.length > 0) { const availableVerses = allVerses.map(v => v.verse).sort((a, b) => a - b); console.log(`[loadPassage] Available verses:`, availableVerses); const verseExists = availableVerses.includes(scrollToVerse); console.log(`[loadPassage] Verse ${scrollToVerse} exists:`, verseExists); if (verseExists) { // Verse exists - use whole chapter (already loaded), scroll will happen automatically verses = allVerses; tab.verse = 1; tab.verseEnd = null; // Keep _scrollToVerse set so autoScrollToSelectedVerse can handle it console.log(`[loadPassage] Verse exists, keeping scrollToVerse:`, tab._scrollToVerse); } else { // Verse doesn't exist - use whole chapter and scroll to closest verse verses = allVerses; tab.verse = 1; tab.verseEnd = null; const closestVerse = availableVerses.reduce((closest, v) => { const distClosest = Math.abs(closest - scrollToVerse); const distV = Math.abs(v - scrollToVerse); return distV < distClosest ? v : closest; }, availableVerses[0]); tab._scrollToVerse = closestVerse || scrollToVerse; console.log(`[loadPassage] Verse doesn't exist, scrolling to closest verse:`, tab._scrollToVerse); } } else { // Couldn't load chapter - this is a real error console.error(`[loadPassage] Failed to load chapter ${tab.book} ${tab.chapter}`); throw new Error(`No verses found for ${tab.book} ${tab.chapter} in ${tcode}`); } } else if (isSpecificVerseRequest && requestedVerseEnd === null) { // Single verse requested (e.g., verse 37) - ALWAYS load whole chapter first, then check if verse exists // This ensures we always have content to display const allVerses = await this.loadLocalPassageFromTranslations(tcode, tab.book, tab.chapter, 1, null); if (allVerses && Array.isArray(allVerses) && allVerses.length > 0) { const availableVerses = allVerses.map(v => v.verse).sort((a, b) => a - b); // Check if requested verse exists const verseExists = availableVerses.includes(requestedVerseStart); if (verseExists) { // Verse exists - load just that verse verses = await this.loadLocalPassageFromTranslations(tcode, tab.book, tab.chapter, requestedVerseStart, null); // If loading single verse fails or returns empty, use whole chapter if (!verses || !Array.isArray(verses) || verses.length === 0) { verses = allVerses; tab.verse = 1; tab.verseEnd = null; } } else { // Verse doesn't exist - use whole chapter and scroll to closest verse verses = allVerses; tab.verse = 1; tab.verseEnd = null; const closestVerse = availableVerses.reduce((closest, v) => { const distClosest = Math.abs(closest - requestedVerseStart); const distV = Math.abs(v - requestedVerseStart); return distV < distClosest ? v : closest; }, availableVerses[0]); tab._scrollToVerse = closestVerse || requestedVerseStart; } } else { // Couldn't load chapter - this is a real error throw new Error(`No verses found for ${tab.book} ${tab.chapter} in ${tcode}`); } } else if (isSpecificVerseRequest && requestedVerseEnd !== null) { // Verse range requested - check if verses exist const allVerses = await this.loadLocalPassageFromTranslations(tcode, tab.book, tab.chapter, 1, null); if (allVerses && Array.isArray(allVerses) && allVerses.length > 0) { const availableVerses = allVerses.map(v => v.verse).sort((a, b) => a - b); let verseExists = true; for (let v = requestedVerseStart; v <= requestedVerseEnd; v++) { if (!availableVerses.includes(v)) { verseExists = false; break; } } if (verseExists) { verses = await this.loadLocalPassageFromTranslations(tcode, tab.book, tab.chapter, requestedVerseStart, requestedVerseEnd); } else { verses = allVerses; tab.verse = 1; tab.verseEnd = null; const closestVerse = availableVerses.reduce((closest, v) => { const distClosest = Math.abs(closest - requestedVerseStart); const distV = Math.abs(v - requestedVerseStart); return distV < distClosest ? v : closest; }, availableVerses[0]); tab._scrollToVerse = closestVerse || requestedVerseStart; } } else { throw new Error(`No verses found for ${tab.book} ${tab.chapter} in ${tcode}`); } } else { // Loading whole chapter (verse is 1 or null, verseEnd is null) verses = await this.loadLocalPassageFromTranslations(tcode, tab.book, tab.chapter, tab.verse, tab.verseEnd); } // CRITICAL FIX: Final validation - ensure we have verses before displaying if (!verses || !Array.isArray(verses) || verses.length === 0) { // Last resort: try loading whole chapter one more time console.error(`[loadPassage] No verses returned, attempting emergency whole chapter load`); verses = await this.loadLocalPassageFromTranslations(tcode, tab.book, tab.chapter, 1, null); if (!verses || !Array.isArray(verses) || verses.length === 0) { throw new Error(`No verses returned for ${tab.book} ${tab.chapter} in ${tcode}`); } // Update tab state to whole chapter tab.verse = 1; tab.verseEnd = null; } // CRITICAL FIX: Double-check we have valid verses array with content if (!verses || !Array.isArray(verses) || verses.length === 0) { console.error(`[loadPassage] CRITICAL: Still no verses after all attempts!`, { book: tab.book, chapter: tab.chapter, translation: tcode }); throw new Error(`No verses returned for ${tab.book} ${tab.chapter} in ${tcode}`); } // CRITICAL FIX: Validate verses have text content const versesWithText = verses.filter(v => v && v.text && String(v.text).trim().length > 0); if (versesWithText.length === 0) { console.error(`[loadPassage] CRITICAL: Verses array has no text content!`, verses); throw new Error(`No text content found for ${tab.book} ${tab.chapter} in ${tcode}`); } // CRITICAL FIX: Check again if cancelled after async operation if (tab._loadToken !== loadToken) { console.log(`[loadPassage] Load cancelled for tab ${tab.id}`); return; } console.log(`[loadPassage] About to display passage, verses count:`, verses.length, `scrollToVerse:`, tab._scrollToVerse); try { if (tab._scrollToVerse && bibleText) bibleText.style.visibility = 'hidden'; } catch(_) {} // CRITICAL FIX: Use verses with text, or fallback to all verses this.displayPassage(tab, versesWithText.length > 0 ? versesWithText : verses, bibleText, chapterHeader); // CRITICAL FIX: Ensure scrolling happens immediately after display - use multiple retries try { const ra = document.getElementById(`readingArea${tab.id}`); if (tab._scrollToVerse) { console.log(`[loadPassage] Scrolling to verse ${tab._scrollToVerse} - will retry up to 10 times`); // Use multiple requestAnimationFrame calls and retries to ensure scrolling works let retryCount = 0; const maxRetries = 10; const attemptScroll = () => { try { if (typeof this.autoScrollToSelectedVerse === 'function') { this.autoScrollToSelectedVerse(tab.id); // Check if scroll worked by verifying the target verse is visible setTimeout(() => { const targetEl = document.getElementById(`v-${tab.id}-${tab._scrollToVerse}`); if (!targetEl && retryCount < maxRetries) { retryCount++; console.log(`[loadPassage] Scroll retry ${retryCount}/${maxRetries}`); requestAnimationFrame(attemptScroll); } else if (targetEl) { console.log(`[loadPassage] Successfully scrolled to verse ${tab._scrollToVerse}`); } }, 100); } } catch(e) { console.error(`[loadPassage] Error scrolling:`, e); if (retryCount < maxRetries) { retryCount++; setTimeout(attemptScroll, 50); } } }; // Start scrolling after DOM is ready requestAnimationFrame(() => { requestAnimationFrame(() => { attemptScroll(); }); }); } else if (ra) { // Honor saved scroll if we are reloading same passage or flagged to restore const wantsRestore = tab._pendingScrollRestore || false; const target = wantsRestore ? Math.max(0, Number(tab.scrollTop || 0)) : 0; ra.scrollTop = target; // Fade only for non-verse jumps if (bibleText) { // Use opacity transition to avoid class reflow double-flicker try { bibleText.style.willChange = 'opacity'; } catch(_) {} bibleText.style.transition = 'opacity 160ms ease'; // Ensure visible for non-verse jump bibleText.style.opacity = '1'; setTimeout(() => { try { bibleText.style.transition = ''; bibleText.style.willChange = 'auto'; } catch(_) {} }, 200); } } tab._pendingScrollRestore = false; } catch(_) {} tab.loaded = true; tab._lastLoadedTranslation = tab.translation; tab._previousTranslation = null; // Clear the skip flag if it was set tab._skipAnnotationClearInLoadPassage = false; // CRITICAL FIX: Clear the stored previous translation after using it tab._previousTranslationBeforeLoad = null; this.renderTabs(); setTimeout(() => { setupAnnotationForActiveTab(); loadAnnotations(tabId); ensurePenDrawingReady(tabId); ensureContentResizeObserver(tabId); ensureScrollMonitor(tabId); // Finalize canvas sizing and redraw after layout settles setTimeout(() => { try { getOrCreateAnnotationCanvas(tabId); ensureNonZeroCanvas(tabId); updateVisibleCanvas(tabId); redrawAllStrokes(tabId); ensurePenDrawingReady(tabId); ensureCanvasAndPenReady(tabId); } catch (_) {} }, 120); // Auto-scroll handled immediately above when _scrollToVerse is set // Additional delayed recalculation to catch late layout changes setTimeout(() => { console.log('[DEBUG] Delayed canvas recalculation for long passages'); getOrCreateAnnotationCanvas(tabId); updateVisibleCanvas(tabId); }, 500); }, 100); return; } catch (e) { // Fallbacks for legacy NKJV loader if file names differ if (tcode === 'NKJV') { try { // CRITICAL FIX: Check if load was cancelled if (tab._loadToken !== loadToken) { return; } const requestedVerseStart = tab.verse !== null ? Number(tab.verse) : null; const requestedVerseEnd = tab.verseEnd !== null ? Number(tab.verseEnd) : null; // CRITICAL FIX: Always ensure we have valid verses to display (same logic as main loader) let verses = null; if (requestedVerseStart !== null && requestedVerseEnd !== null) { // First, load the whole chapter to check what verses exist const allVerses = await this.loadLocalNKJVPassage(tab.book, tab.chapter, 1, null); if (allVerses && Array.isArray(allVerses) && allVerses.length > 0) { const availableVerses = allVerses.map(v => v.verse).sort((a, b) => a - b); // Check if requested verse(s) exist let verseExists = true; for (let v = requestedVerseStart; v <= requestedVerseEnd; v++) { if (!availableVerses.includes(v)) { verseExists = false; break; } } if (verseExists) { // Verse exists - load just that verse verses = await this.loadLocalNKJVPassage(tab.book, tab.chapter, requestedVerseStart, requestedVerseEnd); } else { // Verse doesn't exist - use whole chapter and scroll to closest verse verses = allVerses; tab.verse = 1; tab.verseEnd = null; const closestVerse = availableVerses.reduce((closest, v) => { const distClosest = Math.abs(closest - requestedVerseStart); const distV = Math.abs(v - requestedVerseStart); return distV < distClosest ? v : closest; }, availableVerses[0]); tab._scrollToVerse = closestVerse || requestedVerseStart; } } else { throw new Error(`No verses found for ${tab.book} ${tab.chapter} in NKJV`); } } else { // Loading whole chapter verses = await this.loadLocalNKJVPassage(tab.book, tab.chapter, tab.verse, tab.verseEnd); } // CRITICAL FIX: Final validation - ensure we have verses before displaying if (!verses || !Array.isArray(verses) || verses.length === 0) { throw new Error(`No verses returned for ${tab.book} ${tab.chapter} in NKJV`); } // CRITICAL FIX: Check again if cancelled after async operation if (tab._loadToken !== loadToken) { return; } try { if (tab._scrollToVerse && bibleText) bibleText.style.visibility = 'hidden'; } catch(_) {} this.displayPassage(tab, verses, bibleText, chapterHeader); try { if (tab._scrollToVerse) { // Reveal handled inside autoScrollToSelectedVerse after positioning try { if (typeof this.autoScrollToSelectedVerse === 'function') this.autoScrollToSelectedVerse(tab.id); } catch(_) {} } else { const ra = document.getElementById(`readingArea${tab.id}`); if (ra) ra.scrollTop = 0; if (bibleText) { try { bibleText.style.willChange = 'opacity'; } catch(_) {} bibleText.style.transition = 'opacity 160ms ease'; bibleText.style.opacity = '1'; setTimeout(() => { try { bibleText.style.transition = ''; bibleText.style.willChange = 'auto'; } catch(_) {} }, 200); } } } catch(_) {} tab.loaded = true; this.renderTabs(); setTimeout(() => { setupAnnotationForActiveTab(); loadAnnotations(tabId); ensureContentResizeObserver(tabId); ensureScrollMonitor(tabId); }, 100); return; } catch(_) {} } // API fallback for MSG via API.Bible when no local JSON is present if (tcode === 'MSG') { try { // CRITICAL FIX: Check if load was cancelled if (tab._loadToken !== loadToken) { return; } const requestedVerseStart = tab.verse !== null ? Number(tab.verse) : null; const requestedVerseEnd = tab.verseEnd !== null ? Number(tab.verseEnd) : null; const verses = await this.fetchMSGFromApiBible(passage); // CRITICAL FIX: Validate verses before displaying (MSG can return string or array) if (!verses || (typeof verses === 'string' && !verses.trim()) || (Array.isArray(verses) && verses.length === 0)) { throw new Error(`No verses returned for ${passage} in MSG`); } // CRITICAL FIX: If we requested a specific verse, check if it exists (only works if verses is an array) if (Array.isArray(verses) && requestedVerseStart !== null && requestedVerseEnd !== null && verses.length > 0) { const returnedVerses = verses.map(v => v.verse).sort((a, b) => a - b); const requestedRange = []; for (let v = requestedVerseStart; v <= requestedVerseEnd; v++) { requestedRange.push(v); } const hasRequestedRange = requestedRange.every(v => returnedVerses.includes(v)); if (!hasRequestedRange && returnedVerses.length > requestedRange.length) { // Verse doesn't exist - switch to whole chapter tab.verse = 1; tab.verseEnd = null; // CRITICAL FIX: Keep scroll target at requested verse so it scrolls to that area const closestVerse = returnedVerses.reduce((closest, v) => { const distClosest = Math.abs(closest - requestedVerseStart); const distV = Math.abs(v - requestedVerseStart); return distV < distClosest ? v : closest; }, returnedVerses[0]); tab._scrollToVerse = closestVerse || requestedVerseStart; // Reload with whole chapter const wholeChapterPassage = `${tab.book} ${tab.chapter}`; const wholeChapterVerses = await this.fetchMSGFromApiBible(wholeChapterPassage); if (wholeChapterVerses && ((typeof wholeChapterVerses === 'string' && wholeChapterVerses.trim()) || (Array.isArray(wholeChapterVerses) && wholeChapterVerses.length > 0))) { try { if (tab._scrollToVerse && bibleText) bibleText.style.visibility = 'hidden'; } catch(_) {} this.displayPassage(tab, wholeChapterVerses, bibleText, chapterHeader); // ... rest of display logic ... tab.loaded = true; this.renderTabs(); setTimeout(() => { setupAnnotationForActiveTab(); loadAnnotations(tabId); ensureContentResizeObserver(tabId); ensureScrollMonitor(tabId); }, 100); return; } } } // CRITICAL FIX: Check again if cancelled after async operation if (tab._loadToken !== loadToken) { return; } try { if (tab._scrollToVerse && bibleText) bibleText.style.visibility = 'hidden'; } catch(_) {} this.displayPassage(tab, verses, bibleText, chapterHeader); try { if (tab._scrollToVerse) { // Reveal handled inside autoScrollToSelectedVerse after positioning try { if (typeof this.autoScrollToSelectedVerse === 'function') this.autoScrollToSelectedVerse(tab.id); } catch(_) {} } else { const ra = document.getElementById(`readingArea${tab.id}`); if (ra) { const wantsRestore = tab._pendingScrollRestore || false; const target = wantsRestore ? Math.max(0, Number(tab.scrollTop || 0)) : 0; ra.scrollTop = target; } if (bibleText) { try { bibleText.style.willChange = 'opacity'; } catch(_) {} bibleText.style.transition = 'opacity 160ms ease'; bibleText.style.opacity = '1'; setTimeout(() => { try { bibleText.style.transition = ''; bibleText.style.willChange = 'auto'; } catch(_) {} }, 200); } } tab._pendingScrollRestore = false; } catch(_) {} tab.loaded = true; this.renderTabs(); setTimeout(() => { setupAnnotationForActiveTab(); loadAnnotations(tabId); ensureContentResizeObserver(tabId); ensureScrollMonitor(tabId); try { if (typeof this.autoScrollToSelectedVerse === 'function') this.autoScrollToSelectedVerse(tab.id); } catch(_) {} }, 100); return; } catch(_) {} // Secondary fallback for MSG via jsDelivr (wldeh/bible-api) per-verse JSON try { // CRITICAL FIX: Check if load was cancelled if (tab._loadToken !== loadToken) { return; } // CRITICAL FIX: For jsDelivr, if verse doesn't exist, it will return empty array // We need to check and fallback to whole chapter const requestedVerseStart = tab.verse !== null ? Number(tab.verse) : null; const requestedVerseEnd = tab.verseEnd !== null ? Number(tab.verseEnd) : null; const verses = await this.fetchPassageFromJsdelivr('MSG', tab.book, tab.chapter, tab.verse, tab.verseEnd); // If no verses returned and we requested a specific verse, try whole chapter if ((!verses || verses.length === 0) && requestedVerseEnd !== null && requestedVerseStart !== null) { const wholeChapterVerses = await this.fetchPassageFromJsdelivr('MSG', tab.book, tab.chapter, 1, null); if (wholeChapterVerses && wholeChapterVerses.length > 0) { // Verse doesn't exist - switch to whole chapter tab.verse = 1; tab.verseEnd = null; // CRITICAL FIX: Keep scroll target at requested verse so it scrolls to that area // Find closest valid verse to the requested one const returnedVerses = wholeChapterVerses.map(v => v.verse).sort((a, b) => a - b); const closestVerse = returnedVerses.reduce((closest, v) => { const distClosest = Math.abs(closest - requestedVerseStart); const distV = Math.abs(v - requestedVerseStart); return distV < distClosest ? v : closest; }, returnedVerses[0]); tab._scrollToVerse = closestVerse || requestedVerseStart; // Use the whole chapter verses const finalVerses = wholeChapterVerses; try { if (tab._scrollToVerse && bibleText) bibleText.style.visibility = 'hidden'; } catch(_) {} this.displayPassage(tab, finalVerses, bibleText, chapterHeader); // ... rest of display logic ... tab.loaded = true; this.renderTabs(); setTimeout(() => { setupAnnotationForActiveTab(); loadAnnotations(tabId); ensureContentResizeObserver(tabId); ensureScrollMonitor(tabId); }, 100); return; } } // CRITICAL FIX: Validate verses before displaying if (!verses || !Array.isArray(verses) || verses.length === 0) { throw new Error(`No verses returned for ${tab.book} ${tab.chapter} in MSG`); } // CRITICAL FIX: Check again if cancelled after async operation if (tab._loadToken !== loadToken) { return; } try { if (tab._scrollToVerse && bibleText) bibleText.style.visibility = 'hidden'; } catch(_) {} this.displayPassage(tab, verses, bibleText, chapterHeader); try { if (tab._scrollToVerse) { try { if (typeof this.autoScrollToSelectedVerse === 'function') this.autoScrollToSelectedVerse(tab.id); } catch(_) {} } else { const ra = document.getElementById(`readingArea${tab.id}`); if (ra) { const wantsRestore = tab._pendingScrollRestore || false; const target = wantsRestore ? Math.max(0, Number(tab.scrollTop || 0)) : 0; ra.scrollTop = target; } if (bibleText) { bibleText.classList.remove('smooth-fade-in'); void bibleText.offsetWidth; bibleText.classList.add('smooth-fade-in'); } } tab._pendingScrollRestore = false; } catch(_) {} tab.loaded = true; this.renderTabs(); setTimeout(() => { setupAnnotationForActiveTab(); loadAnnotations(tabId); ensureContentResizeObserver(tabId); ensureScrollMonitor(tabId); try { if (typeof this.autoScrollToSelectedVerse === 'function') this.autoScrollToSelectedVerse(tab.id); } catch(_) {} }, 100); return; } catch(_) {} } throw e; } // CRITICAL FIX: This code path should never be reached - all successful loads return early // If we reach here, it means all loading attempts failed, so show error console.error('[loadPassage] All loading attempts failed - versesArr is null'); if (bibleText) { bibleText.innerHTML = `
Error loading ${tab.translation} passage. Please try again.
`; tab.savedText = bibleText.innerHTML; } try { const ra = document.getElementById(`readingArea${tab.id}`); if (ra) { const wantsRestore = tab._pendingScrollRestore || false; const target = wantsRestore ? Math.max(0, Number(tab.scrollTop || 0)) : 0; ra.scrollTop = target; } tab._pendingScrollRestore = false; } catch(_) {} tab.loaded = true; this.renderTabs(); // Update tab title // Set up annotations after text is displayed setTimeout(() => { setupAnnotationForActiveTab(); loadAnnotations(tabId); // Dynamically track content height and keep canvases in sync on long passages ensureContentResizeObserver(tabId); ensureScrollMonitor(tabId); }, 100); } catch (error) { console.error(`Error loading ${tab.translation} passage:`, error); // Emergency fallback: try jsDelivr chapter so user never sees blank try { await this._emergencyFallbackLoadChapter(tab, tcode); tab.loaded = true; this.renderTabs(); setTimeout(() => { setupAnnotationForActiveTab(); loadAnnotations(tabId); }, 100); return; } catch (fallbackErr) { console.error('Emergency fallback failed:', fallbackErr); } bibleText.innerHTML = `
Error loading ${tab.translation} passage. Please try again.
`; // CRITICAL FIX: Keep header visible but clear title text on error if (chapterHeader) { const titleEl = chapterHeader.querySelector('.chapter-title'); if (titleEl) { titleEl.textContent = ''; } } tab.loaded = true; this.renderTabs(); // Update tab title setTimeout(() => { setupAnnotationForActiveTab(); loadAnnotations(tabId); }, 100); } } // Generic local loader for Translations/{CODE}.json files async loadLocalPassageFromTranslations(code, bookName, chapterNumber, verseStart, verseEnd) { this._localTranslations = this._localTranslations || {}; const upper = String(code || '').toUpperCase(); if (!this._localTranslations[upper]) { const paths = [ `Translations/${upper}.json`, `Translations/${upper}_bible.json`, `./Translations/${upper}.json`, `./Translations/${upper}_bible.json`, `${upper}.json`, `${upper}_bible.json`, `./${upper}.json`, `./${upper}_bible.json` ]; let loaded = null, lastErr = null; for (const p of paths) { try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) { loaded = await r.json(); break; } } catch(e){ lastErr = e; } } if (!loaded) throw lastErr || new Error(`Local translation file not found for ${upper}`); this._localTranslations[upper] = loaded; } const data = this._localTranslations[upper]; const normalizedBook = String(bookName || '').trim().toLowerCase(); // Support common naming variants (e.g., Psalms vs Psalm) without changing displayed labels const bookNameCandidates = (() => { const candidates = [normalizedBook]; if (normalizedBook === 'psalms') candidates.push('psalm'); if (normalizedBook === 'song of solomon') candidates.push('song of songs'); if (normalizedBook === 'song of songs') candidates.push('song of solomon'); return Array.from(new Set(candidates)); })(); let verses = []; // Variant A: books[] structure if (!verses.length && Array.isArray(data?.books)) { const b = data.books.find(x => bookNameCandidates.includes(String(x.name || '').trim().toLowerCase())); const c = b?.chapters?.[Number(chapterNumber) - 1]; const arr = c?.verses; if (Array.isArray(arr)) { verses = arr.map((v, i) => typeof v === 'object' ? { verse: Number(v.verse || v.number || i + 1), text: String(v.text ?? '').trim() } : { verse: i + 1, text: String(v ?? '').trim() } ); } } // Variant B: object tree if (!verses.length && data && typeof data === 'object') { const bookKey = Object.keys(data).find(k => bookNameCandidates.includes(k.trim().toLowerCase())); const bookNode = bookKey ? data[bookKey] : null; const chapterNode = bookNode ? (bookNode[String(chapterNumber)] || bookNode[chapterNumber]) : null; if (chapterNode && typeof chapterNode === 'object') { const keys = Object.keys(chapterNode).filter(k => /^\d+$/.test(k)).sort((a,b)=>Number(a)-Number(b)); if (keys.length) verses = keys.map(k => ({ verse: Number(k), text: String(chapterNode[k] ?? '').trim() })); } else if (Array.isArray(chapterNode)) { verses = chapterNode.map((v,i)=>({ verse: i+1, text: String(v ?? '').trim() })); } } // Variant C: flat array if (!verses.length && Array.isArray(data)) { let rows = data.filter(r => bookNameCandidates.includes(String(r.book || '').trim().toLowerCase()) && Number(r.chapter) === Number(chapterNumber)); if (!rows.length) { const bookNum = getBookNumberForName(bookName); if (bookNum) { rows = data.filter(r => Number(r.book) === Number(bookNum) && Number(r.chapter) === Number(chapterNumber)); } } if (rows.length) verses = rows.map(r => ({ verse: Number(r.verse), text: String(r.text ?? '').trim() })).sort((a,b)=>a.verse-b.verse); } if (!verses.length) throw new Error(`Could not parse local ${upper} JSON structure`); // If verseEnd is null, load the whole chapter if (verseEnd === null) { // CRITICAL FIX: Ensure we never return empty array if (!verses || verses.length === 0) { throw new Error(`No verses found for ${bookName} ${chapterNumber} in ${upper}`); } return verses; } const start = Number(verseStart) || 1; const end = Number(verseEnd) || start; // CRITICAL FIX: Check if requested verses exist before filtering const maxVerse = verses.length > 0 ? Math.max(...verses.map(v => v.verse)) : 0; const minVerse = verses.length > 0 ? Math.min(...verses.map(v => v.verse)) : 0; // If requested verse range is completely outside available verses, return full chapter if (maxVerse > 0 && (start > maxVerse || end < minVerse)) { // Requested verse doesn't exist - return full chapter return verses; } const slice = verses.filter(v => v.verse >= start && v.verse <= end); // CRITICAL FIX: If slice is empty (verse doesn't exist), return full chapter instead of empty array // This ensures that when a non-existent verse is requested, we still show the chapter if (slice.length === 0) { // Verse doesn't exist - return whole chapter so user can see the content console.log(`[loadLocalPassageFromTranslations] Verse ${verseStart}${verseEnd !== verseStart ? '-' + verseEnd : ''} not found in ${bookName} ${chapterNumber}, returning whole chapter`); return verses; // Return whole chapter } // Double-check we're not returning empty array if (!slice || slice.length === 0) { // Last resort: return whole chapter if slice is somehow empty return verses; } return slice; } // Resolve NKJV Bible ID and fetch text from API.Bible async fetchNKJVFromApiBible(passageReference) { try { // Allow overriding the API key and NKJV Bible ID via localStorage for flexibility const storedKey = (typeof localStorage !== 'undefined') ? localStorage.getItem('apiBible.key') : null; const apiKey = (storedKey && storedKey.trim()) || 'df56395856e6d477ff8c4f5b2814d2c7'; const storedNkjvId = (typeof localStorage !== 'undefined') ? localStorage.getItem('apiBible.nkjvId') : null; // Cache bibleId to avoid repeated lookups if (!this._nkjvBibleId) { if (storedNkjvId && storedNkjvId.trim()) { this._nkjvBibleId = storedNkjvId.trim(); console.log('[NKJV] Using NKJV Bible ID from localStorage override:', this._nkjvBibleId); } // Fetch all accessible English Bibles and try to locate NKJV by abbreviation or name if (!this._nkjvBibleId) { const listUrl = 'https://api.scripture.api.bible/v1/bibles?language=eng'; const listResp = await fetch(listUrl, { headers: { 'api-key': apiKey, 'accept': 'application/json' } }); if (!listResp.ok) throw new Error('Failed to list Bibles for NKJV'); const listJson = await listResp.json(); const bibles = (listJson && listJson.data) || []; const match = bibles.find(b => (b.abbreviation || '').toUpperCase() === 'NKJV') || bibles.find(b => (b.name || '').toLowerCase().includes('new king james')); if (!match || !match.id) { // Retry without language filter as a fallback const allResp = await fetch('https://api.scripture.api.bible/v1/bibles', { headers: { 'api-key': apiKey, 'accept': 'application/json' } }); if (allResp.ok) { const allJson = await allResp.json(); const all = (allJson && allJson.data) || []; const match2 = all.find(b => (b.abbreviation || '').toUpperCase() === 'NKJV') || all.find(b => (b.name || '').toLowerCase().includes('new king james')); if (match2 && match2.id) { this._nkjvBibleId = match2.id; } } } else { this._nkjvBibleId = match.id; } } // No interactive prompts if (!this._nkjvBibleId) throw new Error('NKJV Bible not available on API.Bible for this key'); } const qParams = new URLSearchParams({ reference: passageReference, 'content-type': 'text', 'include-verse-numbers': 'true', 'include-chapter-numbers': 'false', 'include-verse-spans': 'false' }); const passageUrl = `https://api.scripture.api.bible/v1/bibles/${this._nkjvBibleId}/passages?${qParams.toString()}`; const resp = await fetch(passageUrl, { headers: { 'api-key': apiKey, 'accept': 'application/json' } }); if (!resp.ok) throw new Error('NKJV passage fetch failed'); const json = await resp.json(); const data = json && json.data ? json.data : {}; // Prefer text content; fall back to verses array if present if (typeof data.content === 'string' && data.content.trim()) { return data.content; } if (Array.isArray(data.passages) && data.passages.length) { const content = data.passages.map(p => typeof p.content === 'string' ? p.content : '').filter(Boolean).join('\n'); if (content.trim()) return content; } if (Array.isArray(data.verses) && data.verses.length) { const lines = data.verses.map(v => { const number = v.verse || v.number || v.verseNumber || ''; const text = (v.text || '').trim(); return number ? `${number} ${text}` : text; }).filter(Boolean); return lines.join('\n'); } // As a last resort, return empty string to let caller render gracefully return ''; } catch (e) { console.error('[NKJV] API.Bible error:', e); throw e; } } // Emergency fallback: load whole chapter from jsDelivr and render immediately async _emergencyFallbackLoadChapter(tab, tcode) { try { const verses = await this.fetchPassageFromJsdelivr(tcode, tab.book, tab.chapter, 1, null); if (!verses || (Array.isArray(verses) && verses.length === 0)) { throw new Error('No verses from jsDelivr fallback'); } const versesArr = Array.isArray(verses) ? verses : [{ verse: 1, text: String(verses || '').trim() }]; tab.verse = 1; tab.verseEnd = null; // Keep requested scroll target if present, else first verse const available = versesArr.map(v => v.verse).filter(v => v > 0).sort((a,b)=>a-b); if (available.length) { const target = tab._scrollToVerse && available.includes(Number(tab._scrollToVerse)) ? Number(tab._scrollToVerse) : available[0]; tab._scrollToVerse = target; } else { tab._scrollToVerse = tab._scrollToVerse || 1; } const bibleText = document.getElementById(`bibleText${tab.id}`); const chapterHeader = document.getElementById(`chapterHeader${tab.id}`); this.displayPassage(tab, versesArr, bibleText, chapterHeader); // Scroll with retries let retryCount = 0; const maxRetry = 10; const attemptScroll = () => { try { if (typeof this.autoScrollToSelectedVerse === 'function') { this.autoScrollToSelectedVerse(tab.id); } const targetEl = document.getElementById(`v-${tab.id}-${tab._scrollToVerse}`); if (!targetEl && retryCount < maxRetry) { retryCount++; requestAnimationFrame(attemptScroll); } } catch (_) { if (retryCount < maxRetry) { retryCount++; setTimeout(attemptScroll, 50); } } }; requestAnimationFrame(() => requestAnimationFrame(attemptScroll)); } catch (err) { console.error('[emergencyFallback] failed to load chapter from jsDelivr:', err); throw err; } } // Fetch verses from jsDelivr per-verse JSON and return array [{verse,text}] async fetchPassageFromJsdelivr(version, bookName, chapterNumber, verseStart, verseEnd) { const mapVersion = (v) => String(v || '').toUpperCase(); const ver = mapVersion(version); const normalizedBook = String(bookName || '').trim(); const verses = []; let isWholeChapter = (verseEnd === null || typeof verseEnd === 'undefined' || Number.isNaN(Number(verseEnd))); const start = Number(verseStart) || 1; const end = isWholeChapter ? null : (Number(verseEnd) || Number(verseStart) || 1); if (!isWholeChapter) { for (let v = start; v <= end; v++) { const url = `https://cdn.jsdelivr.net/gh/wldeh/bible-api/bibles/${encodeURIComponent(ver)}/books/${encodeURIComponent(normalizedBook)}/chapters/${encodeURIComponent(String(chapterNumber))}/verses/${encodeURIComponent(String(v))}.json`; try { const r = await fetch(url, { cache: 'no-store' }); if (!r.ok) throw new Error('not ok'); const j = await r.json(); const verseNum = Number(j.verse || j.number || v); const text = String(j.text || j.content || '').trim(); if (text) verses.push({ verse: verseNum, text }); } catch (_) { // continue } } // CRITICAL FIX: If no verses found for requested range, fetch whole chapter instead if (verses.length === 0) { // Fallback to whole chapter mode isWholeChapter = true; } } if (isWholeChapter) { // Attempt to fetch verse-by-verse until several consecutive misses indicate end of chapter let v = 1; let consecutiveMisses = 0; const maxVerseProbe = 250; // generous upper bound const maxMisses = 5; while (v <= maxVerseProbe && consecutiveMisses < maxMisses) { const url = `https://cdn.jsdelivr.net/gh/wldeh/bible-api/bibles/${encodeURIComponent(ver)}/books/${encodeURIComponent(normalizedBook)}/chapters/${encodeURIComponent(String(chapterNumber))}/verses/${encodeURIComponent(String(v))}.json`; try { const r = await fetch(url, { cache: 'no-store' }); if (!r.ok) { consecutiveMisses++; v++; continue; } const j = await r.json(); const verseNum = Number(j.verse || j.number || v); const text = String(j.text || j.content || '').trim(); if (text) { verses.push({ verse: verseNum, text }); consecutiveMisses = 0; } else { consecutiveMisses++; } } catch (_) { consecutiveMisses++; } v++; } } if (!verses.length) throw new Error('No verses from jsDelivr'); return verses; } // Resolve MSG Bible ID and fetch verses array from API.Bible (falls back to text parsing) async fetchMSGFromApiBible(passageReference) { try { const storedKey = (typeof localStorage !== 'undefined') ? localStorage.getItem('apiBible.key') : null; const apiKey = (storedKey && storedKey.trim()) || 'df56395856e6d477ff8c4f5b2814d2c7'; const storedMsgId = (typeof localStorage !== 'undefined') ? localStorage.getItem('apiBible.msgId') : null; if (!this._msgBibleId) { if (storedMsgId && storedMsgId.trim()) { this._msgBibleId = storedMsgId.trim(); console.log('[MSG] Using MSG Bible ID from localStorage override:', this._msgBibleId); } if (!this._msgBibleId) { const listUrl = 'https://api.scripture.api.bible/v1/bibles?language=eng'; const listResp = await fetch(listUrl, { headers: { 'api-key': apiKey, 'accept': 'application/json' } }); if (!listResp.ok) throw new Error('Failed to list Bibles for MSG'); const listJson = await listResp.json(); const bibles = (listJson && listJson.data) || []; const match = bibles.find(b => (b.abbreviation || '').toUpperCase() === 'MSG') || bibles.find(b => (b.name || '').toLowerCase().includes('message')); if (!match || !match.id) { const allResp = await fetch('https://api.scripture.api.bible/v1/bibles', { headers: { 'api-key': apiKey, 'accept': 'application/json' } }); if (allResp.ok) { const allJson = await allResp.json(); const all = (allJson && allJson.data) || []; const match2 = all.find(b => (b.abbreviation || '').toUpperCase() === 'MSG') || all.find(b => (b.name || '').toLowerCase().includes('message')); if (match2 && match2.id) this._msgBibleId = match2.id; } } else { this._msgBibleId = match.id; } } if (!this._msgBibleId) throw new Error('MSG Bible not available on API.Bible for this key'); } const qParams = new URLSearchParams({ reference: passageReference, 'content-type': 'text', 'include-verse-numbers': 'true', 'include-chapter-numbers': 'false', 'include-verse-spans': 'false' }); const urlJson = `https://api.scripture.api.bible/v1/bibles/${this._msgBibleId}/passages?${qParams.toString()}`; const resp = await fetch(urlJson, { headers: { 'api-key': apiKey, 'accept': 'application/json' } }); if (!resp.ok) throw new Error('MSG passage fetch failed'); const json = await resp.json(); const data = json && json.data ? json.data : {}; // Prefer plain text content for consistent rendering if (typeof data.content === 'string' && data.content.trim()) { return data.content; } if (Array.isArray(data.verses) && data.verses.length) { const verses = data.verses.map(v => ({ verse: Number(v.verse || v.verseNumber || 0), text: String(v.text || '').trim() })) .filter(v => v.verse && v.text); if (verses.length) return verses; } return ''; } catch (e) { console.error('[MSG] API.Bible error:', e); throw e; } } // Load ESV verses from a local JSON file (ESV_bible or ESV_bible.json) and return [{verse, text}, ...] async loadLocalESVPassage(bookName, chapterNumber, verseStart, verseEnd) { if (!this._esvLocalData) { const candidates = ['ESV_bible.json', 'ESV_bible', './ESV_bible.json', './ESV_bible']; let loaded = null; let lastErr = null; for (const path of candidates) { try { const res = await fetch(path, { cache: 'no-cache' }); if (res.ok) { loaded = await res.json(); break; } } catch (e) { lastErr = e; } } if (!loaded) throw lastErr || new Error('Local ESV_bible not found'); this._esvLocalData = loaded; } const data = this._esvLocalData; const normalizedBook = String(bookName || '').trim().toLowerCase(); let verses = []; // Variant A: { books: [ { name, chapters: [ { verses: [string|{verse,text}] } ] } ] } if (!verses.length && Array.isArray(data?.books)) { const b = data.books.find(x => String(x.name || '').trim().toLowerCase() === normalizedBook); const c = b?.chapters?.[Number(chapterNumber) - 1]; const arr = c?.verses; if (Array.isArray(arr)) { verses = arr.map((v, i) => typeof v === 'object' ? { verse: Number(v.verse || v.number || i + 1), text: String(v.text ?? '').trim() } : { verse: i + 1, text: String(v ?? '').trim() } ); } } // Variant B: object tree { [Book]: { [Chapter]: { [Verse]: text } } } if (!verses.length && data && typeof data === 'object') { const bookKey = Object.keys(data).find(k => k.trim().toLowerCase() === normalizedBook); const bookNode = bookKey ? data[bookKey] : null; const chapterNode = bookNode ? (bookNode[String(chapterNumber)] || bookNode[chapterNumber]) : null; if (chapterNode && typeof chapterNode === 'object') { const keys = Object.keys(chapterNode).filter(k => /^\d+$/.test(k)).sort((a, b) => Number(a) - Number(b)); if (keys.length) { verses = keys.map(k => ({ verse: Number(k), text: String(chapterNode[k] ?? '').trim() })); } } else if (Array.isArray(chapterNode)) { verses = chapterNode.map((v, i) => ({ verse: i + 1, text: String(v ?? '').trim() })); } } // Variant C: flat array of records [{ book, chapter, verse, text }] if (!verses.length && Array.isArray(data)) { const rows = data.filter(r => String(r.book || '').trim().toLowerCase() === normalizedBook && Number(r.chapter) === Number(chapterNumber)); if (rows.length) { verses = rows.map(r => ({ verse: Number(r.verse), text: String(r.text ?? '').trim() })) .sort((a, b) => a.verse - b.verse); } } if (!verses.length) throw new Error('Could not parse local ESV JSON structure'); // If verseEnd is null, load the whole chapter if (verseEnd === null) { return verses; } const start = Number(verseStart) || 1; const end = Number(verseEnd) || start; // CRITICAL FIX: Check if requested verses exist before filtering const maxVerse = verses.length > 0 ? Math.max(...verses.map(v => v.verse)) : 0; const minVerse = verses.length > 0 ? Math.min(...verses.map(v => v.verse)) : 0; // If requested verse range is completely outside available verses, return full chapter if (maxVerse > 0 && (start > maxVerse || end < minVerse)) { // Requested verse doesn't exist - return full chapter return verses; } const slice = verses.filter(v => v.verse >= start && v.verse <= end); // CRITICAL FIX: If slice is empty (verse doesn't exist), return full chapter instead of empty array return slice.length ? slice : verses; } // Load NIV verses from a local JSON file (NIV_bible or NIV_bible.json) and return [{verse, text}, ...] async loadLocalNIVPassage(bookName, chapterNumber, verseStart, verseEnd) { if (!this._nivLocalData) { const candidates = ['NIV_bible.json', 'NIV_bible', './NIV_bible.json', './NIV_bible']; let loaded = null; let lastErr = null; for (const path of candidates) { try { const res = await fetch(path, { cache: 'no-cache' }); if (res.ok) { loaded = await res.json(); break; } } catch (e) { lastErr = e; } } if (!loaded) throw lastErr || new Error('Local NIV_bible not found'); this._nivLocalData = loaded; } const data = this._nivLocalData; const normalizedBook = String(bookName || '').trim().toLowerCase(); let verses = []; // Variant A: { books: [ { name, chapters: [ { verses: [string|{verse,text}] } ] } ] } if (!verses.length && Array.isArray(data?.books)) { const b = data.books.find(x => String(x.name || '').trim().toLowerCase() === normalizedBook); const c = b?.chapters?.[Number(chapterNumber) - 1]; const arr = c?.verses; if (Array.isArray(arr)) { verses = arr.map((v, i) => typeof v === 'object' ? { verse: Number(v.verse || v.number || i + 1), text: String(v.text ?? '').trim() } : { verse: i + 1, text: String(v ?? '').trim() } ); } } // Variant B: object tree { [Book]: { [Chapter]: { [Verse]: text } } } if (!verses.length && data && typeof data === 'object') { const bookKey = Object.keys(data).find(k => k.trim().toLowerCase() === normalizedBook); const bookNode = bookKey ? data[bookKey] : null; const chapterNode = bookNode ? (bookNode[String(chapterNumber)] || bookNode[chapterNumber]) : null; if (chapterNode && typeof chapterNode === 'object') { const keys = Object.keys(chapterNode).filter(k => /^\d+$/.test(k)).sort((a, b) => Number(a) - Number(b)); if (keys.length) { verses = keys.map(k => ({ verse: Number(k), text: String(chapterNode[k] ?? '').trim() })); } } else if (Array.isArray(chapterNode)) { verses = chapterNode.map((v, i) => ({ verse: i + 1, text: String(v ?? '').trim() })); } } // Variant C: flat array of records [{ book, chapter, verse, text }] if (!verses.length && Array.isArray(data)) { const rows = data.filter(r => String(r.book || '').trim().toLowerCase() === normalizedBook && Number(r.chapter) === Number(chapterNumber)); if (rows.length) { verses = rows.map(r => ({ verse: Number(r.verse), text: String(r.text ?? '').trim() })) .sort((a, b) => a.verse - b.verse); } } if (!verses.length) throw new Error('Could not parse local NIV JSON structure'); // If verseEnd is null, load the whole chapter if (verseEnd === null) { return verses; } const start = Number(verseStart) || 1; const end = Number(verseEnd) || start; // CRITICAL FIX: Check if requested verses exist before filtering const maxVerse = verses.length > 0 ? Math.max(...verses.map(v => v.verse)) : 0; const minVerse = verses.length > 0 ? Math.min(...verses.map(v => v.verse)) : 0; // If requested verse range is completely outside available verses, return full chapter if (maxVerse > 0 && (start > maxVerse || end < minVerse)) { // Requested verse doesn't exist - return full chapter return verses; } const slice = verses.filter(v => v.verse >= start && v.verse <= end); // CRITICAL FIX: If slice is empty (verse doesn't exist), return full chapter instead of empty array return slice.length ? slice : verses; } // Load NKJV verses from local NKJV_bible(.json) file; return array of { verse, text } async loadLocalNKJVPassage(bookName, chapterNumber, verseStart, verseEnd) { if (!this._nkjvLocalData) { const candidates = ['NKJV_bible.json', 'NKJV_bible', './NKJV_bible.json', './NKJV_bible']; let loaded = null; let lastErr = null; for (const path of candidates) { try { const res = await fetch(path, { cache: 'no-cache' }); if (res.ok) { loaded = await res.json(); break; } } catch (e) { lastErr = e; } } if (!loaded) throw lastErr || new Error('Local NKJV_bible not found'); this._nkjvLocalData = loaded; } const data = this._nkjvLocalData; const normalizedBook = String(bookName || '').trim().toLowerCase(); let verses = []; // Variant A: { books: [ { name, chapters: [ { verses: [string|{verse,text}] } ] } ] } if (!verses.length && Array.isArray(data?.books)) { const b = data.books.find(x => String(x.name || '').trim().toLowerCase() === normalizedBook); const c = b?.chapters?.[Number(chapterNumber) - 1]; const arr = c?.verses; if (Array.isArray(arr)) { verses = arr.map((v, i) => typeof v === 'object' ? { verse: Number(v.verse || v.number || i + 1), text: String(v.text ?? '').trim() } : { verse: i + 1, text: String(v ?? '').trim() } ); } } // Variant B: object tree { [Book]: { [Chapter]: { [Verse]: text } } } if (!verses.length && data && typeof data === 'object') { const bookKey = Object.keys(data).find(k => k.trim().toLowerCase() === normalizedBook); const bookNode = bookKey ? data[bookKey] : null; const chapterNode = bookNode ? (bookNode[String(chapterNumber)] || bookNode[chapterNumber]) : null; if (chapterNode && typeof chapterNode === 'object') { const keys = Object.keys(chapterNode).filter(k => /^\d+$/.test(k)).sort((a, b) => Number(a) - Number(b)); if (keys.length) { verses = keys.map(k => ({ verse: Number(k), text: String(chapterNode[k] ?? '').trim() })); } } else if (Array.isArray(chapterNode)) { verses = chapterNode.map((v, i) => ({ verse: i + 1, text: String(v ?? '').trim() })); } } // Variant C: flat array of records if (!verses.length && Array.isArray(data)) { const rows = data.filter(r => String(r.book || '').trim().toLowerCase() === normalizedBook && Number(r.chapter) === Number(chapterNumber)); if (rows.length) { verses = rows.map(r => ({ verse: Number(r.verse), text: String(r.text ?? '').trim() })) .sort((a, b) => a.verse - b.verse); } } if (!verses.length) throw new Error('Could not parse local NKJV JSON structure'); // If verseEnd is null, load the whole chapter if (verseEnd === null) { return verses; } const start = Number(verseStart) || 1; const end = Number(verseEnd) || start; const slice = verses.filter(v => v.verse >= start && v.verse <= end); return slice.length ? slice : verses; } displayPassage(tab, textOrVerses, bibleText, chapterHeader) { // This panel is now rendering THIS passage — stamp the fast-switch cache key. try { if (tab && tab._panelEl && tab.mode === 'bible') tab._panelKey = [tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation].join('|'); } catch (_) {} // Tag the reading area with the active translation for CSS targeting (e.g., ESV verse-number style) try { const readingArea = document.getElementById(`readingArea${tab.id}`); if (readingArea) { const all = ['esv','niv','nlt','kjv','nkjv','nasb','csb','msg','amp']; all.forEach(t => readingArea.classList.remove(`translation-${t}`)); if (tab.translation) { readingArea.classList.add(`translation-${String(tab.translation).toLowerCase()}`); } } } catch (_) {} // Always update and show the header when we have a passage to display if (chapterHeader && tab.book && tab.chapter && tab.verse) { const refText = tab.verseEnd === null ? `${tab.book} ${tab.chapter}` : `${tab.book} ${tab.chapter}:${tab.verse}${tab.verseEnd && tab.verseEnd !== tab.verse ? '-' + tab.verseEnd : ''}`; const refSpan = `${refText}`; const headerContent = `${refSpan} ${tab.translation}`; // Build a grid header: left control cluster, centered title, right spacer matching left width chapterHeader.innerHTML = `
${headerContent}
`; try { const leftCluster = chapterHeader.querySelector('.chapter-left-controls'); const rightSpacer = chapterHeader.querySelector(`#chapterRightSpacer${tab.id}`); const applySpacerWidth = () => { try { if (!leftCluster || !rightSpacer) return; const w = Math.ceil(leftCluster.getBoundingClientRect().width); rightSpacer.style.width = (w > 0 ? w : 40) + 'px'; } catch (_) {} }; applySpacerWidth(); setTimeout(applySpacerWidth, 0); setTimeout(applySpacerWidth, 150); if (window._spacerResizeHandler) { try { window.removeEventListener('resize', window._spacerResizeHandler, { passive: true }); } catch (_) {} } window._spacerResizeHandler = applySpacerWidth; window.addEventListener('resize', applySpacerWidth, { passive: true }); } catch (_) {} // CRITICAL FIX: Header stays visible, we just update its content // Ensure header is visible (in case it was hidden) chapterHeader.style.display = 'block'; tab.savedHeader = chapterHeader.innerHTML; console.log('[DEBUG] Updated chapter header with back button:', headerContent); // Wire up the back button and translation dropdown without any header scroll interception try { const backBtn = chapterHeader.querySelector('.header-back-btn[data-action="back-to-selector"]'); if (backBtn) { backBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const selector = document.getElementById(`selectorContainer${tab.id}`); const headerEl = document.getElementById(`chapterHeader${tab.id}`); const bibleTextEl = document.getElementById(`bibleText${tab.id}`); const readingAreaEl = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'block'; if (headerEl) headerEl.style.display = 'none'; if (bibleTextEl) bibleTextEl.innerHTML = ''; if (readingAreaEl) readingAreaEl.style.display = 'none'; tab.loaded = false; tab.book = ''; tab.chapter = null; tab.verse = null; tab.verseEnd = null; this.populateSelectors(tab); this.renderTabs(); }); } const topBtn = chapterHeader.querySelector('.header-top-btn[data-action="scroll-to-top"]'); if (topBtn) { const scrollToTopHandler = (e) => { try { e.preventDefault(); e.stopPropagation(); } catch(_) {} try { const ra = document.getElementById(`readingArea${tab.id}`); if (ra) { ra.scrollTop = 0; const bt = document.getElementById(`bibleText${tab.id}`); if (bt) { bt.classList.remove('smooth-fade-in'); void bt.offsetWidth; bt.classList.add('smooth-fade-in'); } } } catch (_) {} }; topBtn.addEventListener('click', scrollToTopHandler, { passive: false }); topBtn.addEventListener('touchend', scrollToTopHandler, { passive: false }); } const downBtn = chapterHeader.querySelector('.header-down-btn[data-action="scroll-to-bottom"]'); if (downBtn) { const scrollToBottomHandler = (e) => { try { e.preventDefault(); e.stopPropagation(); } catch(_) {} try { const bt = document.getElementById(`bibleText${tab.id}`); const ra = document.getElementById(`readingArea${tab.id}`); if (!bt || !ra) return; let lastVerseNum = null; try { const verseNodes = bt.querySelectorAll('.verse[data-verse]'); verseNodes.forEach(el => { const v = parseInt(el.getAttribute('data-verse') || '0', 10); if (!isNaN(v)) lastVerseNum = (lastVerseNum == null) ? v : Math.max(lastVerseNum, v); }); } catch (_) {} if (typeof lastVerseNum === 'number' && !isNaN(lastVerseNum)) { tab._scrollToVerse = lastVerseNum; try { this.autoScrollToSelectedVerse(tab.id); } catch(_) {} } else { const maxScroll = Math.max(0, (ra.scrollHeight - ra.clientHeight) || 0); ra.scrollTop = maxScroll; } } catch (_) {} }; downBtn.addEventListener('click', scrollToBottomHandler, { passive: false }); downBtn.addEventListener('touchend', scrollToBottomHandler, { passive: false }); } // Wire up chapter navigation buttons (-5 / +5) regardless of verse range { const upBtn = chapterHeader.querySelector('.header-nav-btn[data-action="scroll-up-5"]'); const downBtn = chapterHeader.querySelector('.header-nav-btn[data-action="scroll-down-5"]'); const onScrollDelta = (delta) => (ev) => { try { ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation && ev.stopImmediatePropagation(); } catch(_) {} 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 dest = Math.max(1, Math.min(maxVerses, currentVerse + delta)); tab._scrollToVerse = dest; this.autoScrollToSelectedVerse(tab.id); }; if (upBtn && !upBtn._wired) { upBtn.addEventListener('click', onScrollDelta(-5), { passive: false }); upBtn.addEventListener('touchend', onScrollDelta(-5), { passive: false }); upBtn._wired = true; } if (downBtn && !downBtn._wired) { downBtn.addEventListener('click', onScrollDelta(5), { passive: false }); downBtn.addEventListener('touchend', onScrollDelta(5), { passive: false }); downBtn._wired = true; } } // Wire up translation click ? dropdown const transEl = chapterHeader.querySelector(`#chapterTranslation${tab.id}`); if (transEl) { // If already wired on this header, keep pointer style and skip duplicate binding transEl.style.cursor = 'pointer'; transEl.title = 'Change translation'; // Remove any previous handler before adding a new one (prevents no-op on some loads) const handler = (ev) => { try { ev.preventDefault(); ev.stopPropagation(); } catch(_) {} // If we just closed it, suppress immediate re-open on the same tap try { const now = Date.now(); if (chapterHeader && chapterHeader.__translationSuppressUntil && now < chapterHeader.__translationSuppressUntil) { return; } } catch (_) {} // De-dupe rapid multi-touch/click opens try { const now = Date.now(); if (this._lastTranslationOpenTs && (now - this._lastTranslationOpenTs) < 300) return; this._lastTranslationOpenTs = now; } catch (_) {} const existing = document.getElementById(`translationDropdown${tab.id}`); if (existing) { try { existing._cleanup && existing._cleanup(); } catch(_) {} try { existing.remove(); } catch(_) {} // Toggle behavior: if already open, just close and return try { this._lastTranslationOpenTs = Date.now(); } catch(_) {} try { if (chapterHeader) { chapterHeader.__translationOpen = false; chapterHeader.__translationSuppressUntil = Date.now() + 350; } } catch(_) {} return; } const dropdown = document.createElement('div'); dropdown.id = `translationDropdown${tab.id}`; dropdown.className = 'translation-dropdown'; const choices = getVisibleTranslationCodesForTab(tab); if (!choices.length) { dropdown.innerHTML = '
No translations enabled in Settings.
'; } else { dropdown.innerHTML = choices.map(code => ` `).join(''); } // Position directly under the clicked translation text using its live rect // Use fixed positioning so it stays aligned regardless of scroll dropdown.style.position = 'fixed'; const anchorId = `chapterTranslation${tab.id}`; const computeAndApplyPosition = () => { try { const anchorEl = document.getElementById(anchorId) || (ev && ev.currentTarget); const rect = anchorEl ? anchorEl.getBoundingClientRect() : { left: 0, bottom: 0 }; dropdown.style.top = `${Math.round(rect.bottom + 6)}px`; dropdown.style.left = `${Math.round(rect.left)}px`; dropdown.style.zIndex = '10000'; } catch (_) {} }; computeAndApplyPosition(); document.body.appendChild(dropdown); try { if (chapterHeader) chapterHeader.__translationOpen = true; } catch(_) {} // Re-position on next frames to correct multi-touch layout shifts setTimeout(computeAndApplyPosition, 0); setTimeout(computeAndApplyPosition, 50); setTimeout(computeAndApplyPosition, 200); // Track scroll/resize while open const reposition = () => computeAndApplyPosition(); window.addEventListener('scroll', reposition, true); window.addEventListener('resize', reposition, true); // Robust cleanup of global listeners const closeEvents = ['mousedown','touchstart','pointerdown','wheel','keydown']; let cleanupCalled = false; let autoCloseTimer = null; const close = (ev2) => { if (!dropdown.contains(ev2.target) && ev2.target !== transEl) { cleanup(); try { dropdown.remove(); } catch(_) {} } }; const cleanup = () => { if (cleanupCalled) return; cleanupCalled = true; try { if (autoCloseTimer) clearTimeout(autoCloseTimer); } catch(_) {} try { closeEvents.forEach(type => document.removeEventListener(type, close, true)); } catch(_) {} try { window.removeEventListener('scroll', close, true); } catch(_) {} try { window.removeEventListener('scroll', reposition, true); } catch(_) {} try { window.removeEventListener('resize', reposition, true); } catch(_) {} try { if (chapterHeader) { chapterHeader.__translationOpen = false; chapterHeader.__translationSuppressUntil = Date.now() + 150; } } catch(_) {} dropdown._cleanup = null; }; dropdown._cleanup = cleanup; setTimeout(() => { closeEvents.forEach(type => document.addEventListener(type, close, true)); window.addEventListener('scroll', close, true); }, 0); // Auto-hide after 5 seconds autoCloseTimer = setTimeout(() => { try { cleanup(); } catch(_) {} try { dropdown.remove(); } catch(_) {} }, 5000); // Click/touch handlers (unified) dropdown.querySelectorAll('.translation-option').forEach(btn => { const onSelect = (e2) => { try { e2.preventDefault(); e2.stopPropagation(); } catch(_) {} const code = e2.currentTarget.getAttribute('data-code'); if (code && code !== tab.translation) { // Close any open popups when changing translations via dropdown try { if (typeof closeWordPopup === 'function') { closeWordPopup(); } } catch (e) { console.error('Error closing popups on dropdown translation change:', e); } const previousTranslation = tab.translation; // Capture the currently visible verse to restore after translation change try { const ra = document.getElementById(`readingArea${tab.id}`); const btEl = document.getElementById(`bibleText${tab.id}`); if (ra && btEl) { 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 bestVerse = null; 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'); if (!isNaN(v)) { const d = Math.abs(r.top - targetTop); if (d < bestDelta) { bestDelta = d; bestVerse = v; } } }); if (bestVerse != null) { tab._scrollToVerse = bestVerse; try { if (btEl) { btEl.style.opacity = '0'; btEl.style.transition = 'none'; btEl.style.animation = 'none'; btEl.style.willChange = 'opacity'; } } catch (_) {} } } } catch (_) {} const oldPassageKey = (tab.book && tab.chapter) ? getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, previousTranslation, tab.mode, !!tab.ppsync) : null; const newPassageKey = (tab.book && tab.chapter) ? getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, code, tab.mode, !!tab.ppsync) : null; tab._previousTranslationBeforeLoad = previousTranslation; tab._previousTranslation = previousTranslation; tab._prevAnnotationKeyForClear = oldPassageKey; tab._forceAnnotationClear = true; tab._translationChanged = true; tab.translation = code; try { forceClearAnnotationCanvases(tab.id); } catch (_) {} // Reload same passage in new translation if (this.loadPassage) { Promise.resolve(this.loadPassage(tab.id)).then(() => { try { ensureCanvasAndPenReady(tab.id); } catch (_) {} try { ensurePenDrawingReady(tab.id); } catch (_) {} }).catch(() => { try { ensureCanvasAndPenReady(tab.id); } catch (_) {} try { ensurePenDrawingReady(tab.id); } catch (_) {} }).finally(() => { try { tab._translationChanged = false; } catch (_) {} }); } } cleanup(); try { dropdown.remove(); } catch(_) {} }; btn.addEventListener('click', onSelect, { passive: false }); btn.addEventListener('touchend', onSelect, { passive: false }); btn.addEventListener('pointerup', (e2) => { // Only accept primary pointer to avoid double-select if (typeof e2.isPrimary === 'boolean' && e2.isPrimary === false) { try { e2.preventDefault(); e2.stopPropagation(); } catch(_) {} ; return; } onSelect(e2); }, { passive: false }); }); }; transEl.replaceWith(transEl.cloneNode(true)); const newTransEl = chapterHeader.querySelector(`#chapterTranslation${tab.id}`); if (newTransEl) { const safeOpen = (ev) => { // Ignore non-primary pointer duplicates, but still allow single multi-touch open if (ev && typeof ev.isPrimary === 'boolean' && ev.isPrimary === false) { try { ev.preventDefault(); ev.stopPropagation(); } catch(_) {}; return; } try { handler(ev); } catch (_) {} }; // Attach a single primary event to avoid duplicate opens; fall back to click if PointerEvent unsupported if (typeof window !== 'undefined' && 'PointerEvent' in window) { newTransEl.addEventListener('pointerup', safeOpen, { passive: false }); } else { newTransEl.addEventListener('click', safeOpen, { passive: false }); } try { chapterHeader.__translationWired = true; } catch(_) {} } } } catch (_) {} // Install header tap/swipe gestures (tap = scroll to top; swipe = prev/next chapter) try { this.installHeaderGestures(tab); } catch (_) {} } // CRITICAL FIX: Validate input before processing to prevent empty screen if (typeof textOrVerses === 'string') { // ESV text is already formatted, but we need to parse it into proper HTML const trimmedText = textOrVerses.trim(); if (!trimmedText) { bibleText.innerHTML = '
No text available for this passage.
'; tab.savedText = bibleText.innerHTML; return; } const lines = trimmedText.replace(/\r\n|\r/g, '\n').split('\n').filter(l => l.trim()); if (lines.length === 0) { bibleText.innerHTML = '
No text available for this passage.
'; tab.savedText = bibleText.innerHTML; return; } let html = ''; lines.forEach(line => { const match = line.match(/^(\d{1,3})\s+([\s\S]+)/); if (match) { const cleanText = match[2].replace(/\s+/g, ' ').replace(/\n/g, ' ').trim(); // ESV: render verse number without brackets so CSS can style smaller like NIV html += `${match[1]} ${cleanText} `; } else { html += `${line.trim()} `; } }); // CRITICAL FIX: Ensure we have content before setting innerHTML if (!html.trim()) { bibleText.innerHTML = '
No text available for this passage.
'; tab.savedText = bibleText.innerHTML; return; } bibleText.innerHTML = html; // Save the formatted text for restoration tab.savedText = html; } else if (Array.isArray(textOrVerses)) { // CRITICAL FIX: Validate array is not empty before formatting if (textOrVerses.length === 0) { bibleText.innerHTML = '
No text available for this passage.
'; tab.savedText = bibleText.innerHTML; return; } // Format verses with numbers (bracketed), consistent small number style via CSS const formattedText = this.formatVersesWithNumbers(textOrVerses); // CRITICAL FIX: Ensure formatted text is not empty if (!formattedText || formattedText.trim() === '') { bibleText.innerHTML = '
No text available for this passage.
'; tab.savedText = bibleText.innerHTML; return; } bibleText.innerHTML = formattedText; // Save the formatted text for restoration tab.savedText = formattedText; } else { bibleText.innerHTML = '
No text available for this passage.
'; tab.savedText = bibleText.innerHTML; } // SAFEGUARD: If we're about to auto-jump to a specific verse, keep text transparent until positioned try { if (tab && tab._scrollToVerse) { bibleText.style.opacity = '0'; bibleText.style.transition = 'none'; try { bibleText.style.willChange = 'opacity'; } catch(_) {} } } catch (_) {} // Mark verse spans with anchors for precise scrolling try { const existing = bibleText.querySelectorAll('.verse'); existing.forEach((span) => { const numEl = span.querySelector('.verse-number'); if (numEl) { const v = parseInt(numEl.textContent || '0'); if (!isNaN(v)) { span.setAttribute('data-verse', String(v)); span.id = `v-${tab.id}-${v}`; } } }); this.applyVerseHighlights(tab, bibleText); this.installVerseNumberHighlightHandlers(tab, bibleText); } catch (_) {} // Always enable word double-tap listeners for loaded chapters setTimeout(() => { addWordClickListeners(tab.id); }, 50); // CRITICAL FIX: Ensure annotations are set up immediately after passage is displayed // This allows drawing to work immediately on new tabs and after translation switches setTimeout(() => { try { setupAnnotationForActiveTab(); // Ensure annotations are loaded for the current passage if (tab.book && tab.chapter) { loadAnnotations(tab.id); // Ensure drawing is ready immediately ensureCanvasAndPenReady(tab.id); ensurePenDrawingReady(tab.id); } } catch (e) { console.error('[displayPassage] Error setting up annotations:', e); } }, 50); // Note: setupAnnotationForActiveTab is also called from switchTab and loadPassage // to prevent recursive calls and ensure proper timing } // Jump the reading area to a specific verse without scroll animation, then fade autoScrollToSelectedVerse(tabId) { try { const tab = this.tabs.find(t => t.id === tabId); if (!tab || !tab._scrollToVerse) return; const verseNum = Number(tab._scrollToVerse); if (!verseNum || isNaN(verseNum)) return; tab._jumpRetries = (tab._jumpRetries || 0); const ra = document.getElementById(`readingArea${tab.id}`); const target = document.getElementById(`v-${tab.id}-${verseNum}`); if (!ra || !target) { if (tab._jumpRetries < 12) { tab._jumpRetries++; setTimeout(() => { try { this.autoScrollToSelectedVerse && this.autoScrollToSelectedVerse(tabId); } catch(_) {} }, 30); } return; } const offsetParent = ra; const targetRect = target.getBoundingClientRect(); const parentRect = offsetParent.getBoundingClientRect(); if (parentRect.height < 1) { if (tab._jumpRetries < 12) { tab._jumpRetries++; requestAnimationFrame(() => { try { this.autoScrollToSelectedVerse && this.autoScrollToSelectedVerse(tabId); } catch(_) {} }); } 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 delta = (targetRect.top - parentRect.top) + (offsetParent.scrollTop || 0) - headerOffset - 6; offsetParent.scrollTop = Math.max(0, delta); try { const bt = document.getElementById(`bibleText${tab.id}`); if (bt) { // Reveal after precise positioning to avoid verse 1 flash bt.style.visibility = ''; bt.classList.remove('smooth-fade-in'); void bt.offsetWidth; bt.classList.add('smooth-fade-in'); } } catch(_) {} // Re-apply once after layout settles further, then clear flag and reveal with smooth fade setTimeout(() => { try { const ra2 = document.getElementById(`readingArea${tab.id}`); const tgt2 = document.getElementById(`v-${tab.id}-${verseNum}`); if (!ra2 || !tgt2) return; const t2 = tgt2.getBoundingClientRect(); const p2 = ra2.getBoundingClientRect(); let headerOffset2 = 0; try { const header = document.getElementById(`chapterHeader${tab.id}`); if (header && header.style && header.style.display !== 'none') { const hRect2 = header.getBoundingClientRect(); headerOffset2 = Math.max(0, Math.round(hRect2.height)); } } catch (_) {} const d2 = (t2.top - p2.top) + (ra2.scrollTop || 0) - headerOffset2 - 6; ra2.scrollTop = Math.max(0, d2); } catch(_) {} try { const bt2 = document.getElementById(`bibleText${tab.id}`); if (bt2) { bt2.style.transition = 'opacity 160ms ease'; bt2.style.opacity = '1'; setTimeout(() => { try { bt2.style.transition = ''; bt2.style.willChange = 'auto'; } catch(_) {} }, 200); } } catch(_) {} try { delete tab._scrollToVerse; delete tab._jumpRetries; } catch(_) {} }, 120); } catch (_) {} } formatVersesWithNumbers(verses) { // Format verses array from bible-api.com to include verse numbers // Improved to remove gaps and ensure consistent formatting if (!verses || !Array.isArray(verses) || verses.length === 0) { return ''; } let html = ''; verses.forEach(verse => { if (verse.verse && verse.text) { // Clean up the text and ensure proper formatting const cleanText = verse.text.replace(/\s+/g, ' ').replace(/\n/g, ' ').trim(); // Ensure consistent spacing between verse number and text html += `${verse.verse} ${cleanText} `; } }); const result = html.trim(); // CRITICAL FIX: Prevent empty screen - if no valid verses were formatted, return error message if (!result) { return '
No text available for this passage.
'; } return result; } getHighlightedVerseSet(tab) { try { if (!tab) return new Set(); if (!Array.isArray(tab.highlightedVerses)) { tab.highlightedVerses = []; } const normalized = tab.highlightedVerses .map(v => Number(v)) .filter(v => Number.isFinite(v) && v > 0); tab.highlightedVerses = Array.from(new Set(normalized)); return new Set(tab.highlightedVerses); } catch (_) { return new Set(); } } setVerseHighlight(tab, verseEl, verseNumber, shouldHighlight) { try { if (!tab || !verseEl) return; const verseNum = Number(verseNumber); if (!Number.isFinite(verseNum) || verseNum <= 0) return; const highlighted = this.getHighlightedVerseSet(tab); if (shouldHighlight) { highlighted.add(verseNum); verseEl.classList.add('verse-highlighted'); } else { highlighted.delete(verseNum); verseEl.classList.remove('verse-highlighted'); } tab.highlightedVerses = Array.from(highlighted).sort((a, b) => a - b); } catch (_) {} } applyVerseHighlights(tab, bibleText) { try { if (!tab || !bibleText) return; const highlighted = this.getHighlightedVerseSet(tab); const verses = bibleText.querySelectorAll('.verse'); verses.forEach((verseEl) => { const verseNumber = Number(verseEl.getAttribute('data-verse') || 0); if (Number.isFinite(verseNumber) && verseNumber > 0 && highlighted.has(verseNumber)) { verseEl.classList.add('verse-highlighted'); } else { verseEl.classList.remove('verse-highlighted'); } }); } catch (_) {} } installVerseNumberHighlightHandlers(tab, bibleText) { try { if (!tab || !bibleText) return; const verseNumbers = bibleText.querySelectorAll('.verse-number'); verseNumbers.forEach((numEl) => { numEl.style.cursor = 'pointer'; numEl.title = 'Double-tap to highlight verse; tap once to remove highlight'; }); this.installVerseHighlightDelegate(tab, bibleText); } catch (_) {} } installVerseHighlightDelegate(tab, bibleText) { try { if (!tab || !bibleText) return; const readingArea = document.getElementById(`readingArea${tab.id}`); if (!readingArea) return; const DOUBLE_TAP_DELAY_MS = 320; const DOUBLE_TAP_DISTANCE_PX = 16; const MOVE_TOLERANCE_PX = 12; if (readingArea._verseHighlightDelegateInstalled) return; readingArea._verseHighlightDelegateInstalled = true; const state = { pointerId: null, startX: 0, startY: 0, moved: false, verseEl: null, verseNumber: 0, lastTapTime: 0, lastTapX: 0, lastTapY: 0, lastTapVerseNumber: 0 }; const resetState = () => { state.pointerId = null; state.moved = false; state.verseEl = null; state.verseNumber = 0; }; const findVerseNumberAtPoint = (x, y) => { try { const overlays = readingArea.querySelectorAll('.annotation-canvas, .annotation-overlay-canvas, .media-drawing-canvas'); const prev = []; overlays.forEach((node) => { prev.push([node, node.style.pointerEvents]); node.style.pointerEvents = 'none'; }); const rawEl = document.elementFromPoint(x, y); prev.forEach(([node, pointerEvents]) => { node.style.pointerEvents = pointerEvents || ''; }); const numEl = rawEl && rawEl.closest ? rawEl.closest('.verse-number') : null; if (!numEl || !bibleText.contains(numEl)) return null; return numEl; } catch (_) { return null; } }; const onPointerDown = (event) => { if (!event) return; if (typeof event.isPrimary === 'boolean' && !event.isPrimary) return; if (event.pointerType === 'mouse' && event.button !== 0) return; const numEl = findVerseNumberAtPoint(event.clientX, event.clientY); if (!numEl) return; const verseEl = numEl.closest('.verse'); const verseNumber = Number(verseEl?.getAttribute('data-verse') || numEl.textContent || 0); if (!verseEl || !Number.isFinite(verseNumber) || verseNumber <= 0) return; resetState(); state.pointerId = event.pointerId; state.startX = Number(event.clientX) || 0; state.startY = Number(event.clientY) || 0; state.verseEl = verseEl; state.verseNumber = verseNumber; try { event.preventDefault(); event.stopPropagation(); } catch (_) {} }; const onPointerMove = (event) => { if (!event) return; if (state.pointerId === null || event.pointerId !== state.pointerId) return; const dx = Math.abs((Number(event.clientX) || 0) - state.startX); const dy = Math.abs((Number(event.clientY) || 0) - state.startY); if (dx > MOVE_TOLERANCE_PX || dy > MOVE_TOLERANCE_PX) { state.moved = true; } try { event.preventDefault(); event.stopPropagation(); } catch (_) {} }; const onPointerUp = (event) => { if (!event) return; if (state.pointerId === null || event.pointerId !== state.pointerId) return; if (!state.moved && state.verseEl && state.verseNumber > 0) { const now = Date.now(); const dist = Math.hypot((Number(event.clientX) || 0) - state.lastTapX, (Number(event.clientY) || 0) - state.lastTapY); const isDoubleTap = !!state.lastTapTime && (now - state.lastTapTime) <= DOUBLE_TAP_DELAY_MS && dist <= DOUBLE_TAP_DISTANCE_PX && state.lastTapVerseNumber === state.verseNumber; if (isDoubleTap) { this.setVerseHighlight(tab, state.verseEl, state.verseNumber, true); state.lastTapTime = 0; state.lastTapX = 0; state.lastTapY = 0; state.lastTapVerseNumber = 0; } else if (state.verseEl.classList.contains('verse-highlighted')) { this.setVerseHighlight(tab, state.verseEl, state.verseNumber, false); state.lastTapTime = 0; state.lastTapX = 0; state.lastTapY = 0; state.lastTapVerseNumber = 0; } else { state.lastTapTime = now; state.lastTapX = Number(event.clientX) || 0; state.lastTapY = Number(event.clientY) || 0; state.lastTapVerseNumber = state.verseNumber; } } try { event.preventDefault(); event.stopPropagation(); } catch (_) {} resetState(); }; const onPointerCancel = (event) => { if (!event) return; if (state.pointerId === null || event.pointerId !== state.pointerId) return; resetState(); }; readingArea.addEventListener('pointerdown', onPointerDown, { passive: false, capture: true }); readingArea.addEventListener('pointermove', onPointerMove, { passive: false, capture: true }); readingArea.addEventListener('pointerup', onPointerUp, { passive: false, capture: true }); readingArea.addEventListener('pointercancel', onPointerCancel, { passive: true, capture: true }); } catch (_) {} } restoreTabContent(tab) { // Normalize legacy tabs saved without a verse (verse=1 + verseEnd=null = whole chapter). try { if (tab && tab.mode === 'bible' && tab.book) { tab.verse = parseInt(tab.verse, 10) || 1; if (tab.verseEnd == null || isNaN(parseInt(tab.verseEnd, 10))) tab.verseEnd = null; } } catch (_) {} // Restored content = this passage — stamp the fast-switch cache key. try { if (tab && tab._panelEl && tab.mode === 'bible') tab._panelKey = [tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation].join('|'); } catch (_) {} // Restore the Bible text that was previously loaded const bibleText = document.getElementById(`bibleText${tab.id}`); const chapterHeader = document.getElementById(`chapterHeader${tab.id}`); if (bibleText && tab.savedText) { bibleText.innerHTML = tab.savedText; // Re-mark verse spans with data-verse and stable ids so nav buttons work after restore try { const spans = bibleText.querySelectorAll('.verse'); spans.forEach((span) => { const numEl = span.querySelector('.verse-number'); if (numEl) { const v = parseInt(numEl.textContent || '0', 10); if (!isNaN(v) && v > 0) { span.setAttribute('data-verse', String(v)); // Only set id if not already set or belongs to a different tab format if (!span.id || !/^v-\d+-\d+$/.test(span.id)) { span.id = `v-${tab.id}-${v}`; } } } }); this.applyVerseHighlights(tab, bibleText); this.installVerseNumberHighlightHandlers(tab, bibleText); } catch (_) {} } if (chapterHeader && tab.savedHeader) { chapterHeader.innerHTML = tab.savedHeader; chapterHeader.style.display = 'block'; // Ensure header is visible try { // Recompute right spacer width to keep title centered try { const leftCluster = chapterHeader.querySelector('.chapter-left-controls'); const rightSpacer = chapterHeader.querySelector(`#chapterRightSpacer${tab.id}`); const applySpacerWidth = () => { try { if (!leftCluster || !rightSpacer) return; const w = Math.ceil(leftCluster.getBoundingClientRect().width); rightSpacer.style.width = (w > 0 ? w : 40) + 'px'; } catch (_) {} }; applySpacerWidth(); setTimeout(applySpacerWidth, 0); setTimeout(applySpacerWidth, 150); if (window._spacerResizeHandler) { try { window.removeEventListener('resize', window._spacerResizeHandler, { passive: true }); } catch (_) {} } window._spacerResizeHandler = applySpacerWidth; window.addEventListener('resize', applySpacerWidth, { passive: true }); } catch (_) {} // Ensure the chapter title is wrapped as a clickable scripture ref after restore too const titleEl = chapterHeader.querySelector('.chapter-title'); if (titleEl && !titleEl.querySelector('.slide-scripture-ref') && tab.book && tab.chapter && tab.verse) { const refText = tab.verseEnd === null ? `${tab.book} ${tab.chapter}` : `${tab.book} ${tab.chapter}:${tab.verse}${tab.verseEnd && tab.verseEnd !== tab.verse ? '-' + tab.verseEnd : ''}`; const refSpan = document.createElement('span'); refSpan.className = 'slide-scripture-ref'; refSpan.setAttribute('data-ref', refText); refSpan.setAttribute('data-book', tab.book); refSpan.setAttribute('data-chapter', String(tab.chapter)); refSpan.setAttribute('data-verse', String(tab.verse)); refSpan.setAttribute('data-verseend', String(tab.verseEnd || tab.verse)); refSpan.style.cursor = 'pointer'; refSpan.textContent = refText; // Replace leading text with span if needed const transNode = titleEl.querySelector(`#chapterTranslation${tab.id}`); if (transNode) { titleEl.insertBefore(refSpan, transNode); titleEl.insertBefore(document.createTextNode(' '), transNode); } else { titleEl.prepend(refSpan); } } const backBtn = chapterHeader.querySelector('.header-back-btn[data-action="back-to-selector"]'); if (backBtn) { backBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const selector = document.getElementById(`selectorContainer${tab.id}`); const headerEl = document.getElementById(`chapterHeader${tab.id}`); const bibleTextEl = document.getElementById(`bibleText${tab.id}`); const readingAreaEl = document.getElementById(`readingArea${tab.id}`); if (selector) selector.style.display = 'block'; if (headerEl) headerEl.style.display = 'none'; if (bibleTextEl) bibleTextEl.innerHTML = ''; if (readingAreaEl) readingAreaEl.style.display = 'none'; tab.loaded = false; tab.book = ''; tab.chapter = null; tab.verse = null; tab.verseEnd = null; this.populateSelectors(tab); this.renderTabs(); }); } const transEl = chapterHeader.querySelector(`#chapterTranslation${tab.id}`); if (transEl) { // If already wired on this header, keep pointer style and skip duplicate binding transEl.style.cursor = 'pointer'; transEl.title = 'Change translation'; const handler = (ev) => { // Suppress immediate re-open if we just closed it try { const now = Date.now(); if (chapterHeader && chapterHeader.__translationSuppressUntil && now < chapterHeader.__translationSuppressUntil) { try { ev.preventDefault(); ev.stopPropagation(); } catch(_) {} return; } } catch (_) {} // De-dupe rapid multi-touch/click opens try { const now = Date.now(); if (this._lastTranslationOpenTs && (now - this._lastTranslationOpenTs) < 300) return; this._lastTranslationOpenTs = now; } catch (_) {} const existing = document.getElementById(`translationDropdown${tab.id}`); if (existing) { try { existing._cleanup && existing._cleanup(); } catch(_) {} try { existing.remove(); } catch(_) {} // Toggle behavior: if open, close and return try { this._lastTranslationOpenTs = Date.now(); } catch(_) {} try { if (chapterHeader) { chapterHeader.__translationOpen = false; chapterHeader.__translationSuppressUntil = Date.now() + 350; } } catch(_) {} return; } const dropdown = document.createElement('div'); dropdown.id = `translationDropdown${tab.id}`; dropdown.className = 'translation-dropdown'; const choices = getVisibleTranslationCodesForTab(tab); if (!choices.length) { dropdown.innerHTML = '
No translations enabled in Settings.
'; } else { dropdown.innerHTML = choices.map(code => ` `).join(''); } dropdown.style.position = 'fixed'; const anchor = ev && ev.currentTarget ? ev.currentTarget : document.getElementById(`chapterTranslation${tab.id}`); const rect = anchor ? anchor.getBoundingClientRect() : { left: 0, bottom: 0 }; dropdown.style.top = `${rect.bottom + 6}px`; dropdown.style.left = `${rect.left}px`; dropdown.style.zIndex = '10000'; document.body.appendChild(dropdown); try { if (chapterHeader) chapterHeader.__translationOpen = true; } catch(_) {} dropdown.querySelectorAll('.translation-option').forEach(btn => { btn.addEventListener('click', (e) => { const code = e.currentTarget.getAttribute('data-code'); if (code && code !== tab.translation) { // Close any open popups when changing translations via dropdown try { if (typeof closeWordPopup === 'function') { closeWordPopup(); } } catch (err) { console.error('Error closing popups on dropdown translation change:', err); } const previousTranslation = tab.translation; // Capture the currently visible verse before switching translation (restore path) try { const ra = document.getElementById(`readingArea${tab.id}`); const btEl = document.getElementById(`bibleText${tab.id}`); if (ra && btEl) { 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 bestVerse = null; 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'); if (!isNaN(v)) { const d = Math.abs(r.top - targetTop); if (d < bestDelta) { bestDelta = d; bestVerse = v; } } }); if (bestVerse != null) { tab._scrollToVerse = bestVerse; try { if (btEl) { btEl.style.opacity = '0'; btEl.style.transition = 'none'; btEl.style.animation = 'none'; btEl.style.willChange = 'opacity'; } } catch (_) {} } } } catch (_) {} const oldPassageKey = (tab.book && tab.chapter) ? getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, previousTranslation, tab.mode, !!tab.ppsync) : null; const newPassageKey = (tab.book && tab.chapter) ? getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, code, tab.mode, !!tab.ppsync) : null; tab._previousTranslationBeforeLoad = previousTranslation; tab._previousTranslation = previousTranslation; tab._prevAnnotationKeyForClear = oldPassageKey; tab._forceAnnotationClear = true; tab._translationChanged = true; tab.translation = code; try { forceClearAnnotationCanvases(tab.id); } catch (_) {} if (this.loadPassage) { Promise.resolve(this.loadPassage(tab.id)).then(() => { try { ensureCanvasAndPenReady(tab.id); } catch (_) {} try { ensurePenDrawingReady(tab.id); } catch (_) {} }).catch(() => { try { ensureCanvasAndPenReady(tab.id); } catch (_) {} try { ensurePenDrawingReady(tab.id); } catch (_) {} }).finally(() => { try { tab._translationChanged = false; } catch (_) {} }); } } try { cleanup(); } catch(_) {} try { dropdown.remove(); } catch(_) {} }); }); let autoCloseTimer = null; const close = (ev) => { if (!dropdown.contains(ev.target) && ev.target !== transEl) { try { cleanup(); } catch(_) {} try { dropdown.remove(); } catch(_) {} } }; const closeEvents = ['mousedown','touchstart','pointerdown','wheel','keydown']; const cleanup = () => { try { if (autoCloseTimer) clearTimeout(autoCloseTimer); } catch(_) {} try { closeEvents.forEach(type => document.removeEventListener(type, close, true)); } catch(_) {} try { window.removeEventListener('scroll', close, true); } catch(_) {} try { if (chapterHeader) { chapterHeader.__translationOpen = false; chapterHeader.__translationSuppressUntil = Date.now() + 150; } } catch(_) {} dropdown._cleanup = null; }; dropdown._cleanup = cleanup; setTimeout(() => { closeEvents.forEach(type => document.addEventListener(type, close, true)); window.addEventListener('scroll', close, true); }, 0); // Auto-hide after 5 seconds autoCloseTimer = setTimeout(() => { try { cleanup(); } catch(_) {} try { dropdown.remove(); } catch(_) {} }, 5000); }; transEl.replaceWith(transEl.cloneNode(true)); const newTransEl = chapterHeader.querySelector(`#chapterTranslation${tab.id}`); if (newTransEl) { const safeOpen = (ev) => { if (ev && typeof ev.isPrimary === 'boolean' && ev.isPrimary === false) { try { ev.preventDefault(); ev.stopPropagation(); } catch(_) {}; return; } try { handler(ev); } catch (_) {} }; if (typeof window !== 'undefined' && 'PointerEvent' in window) { newTransEl.addEventListener('pointerup', safeOpen, { passive: false }); } else { newTransEl.addEventListener('click', safeOpen, { passive: false }); } try { chapterHeader.__translationWired = true; } catch(_) {} } } // Wire Top button try { const topBtn = chapterHeader.querySelector('.header-top-btn[data-action="scroll-to-top"]'); if (topBtn && !topBtn._wired) { const onTop = (e) => { try { e.preventDefault(); e.stopPropagation(); } catch(_) {} try { const ra = document.getElementById(`readingArea${tab.id}`); if (ra) { ra.scrollTop = 0; const bt = document.getElementById(`bibleText${tab.id}`); if (bt) { bt.classList.remove('smooth-fade-in'); void bt.offsetWidth; bt.classList.add('smooth-fade-in'); } } } catch (_) {} }; topBtn.addEventListener('click', onTop, { passive: false }); topBtn.addEventListener('touchend', onTop, { passive: false }); topBtn._wired = true; } } catch (_) {} // Wire Bottom button try { const bottomBtn = chapterHeader.querySelector('.header-down-btn[data-action="scroll-to-bottom"]'); if (bottomBtn && !bottomBtn._wired) { const onBottom = (e) => { try { e.preventDefault(); e.stopPropagation(); } catch(_) {} try { const bt = document.getElementById(`bibleText${tab.id}`); const ra = document.getElementById(`readingArea${tab.id}`); if (!bt || !ra) return; let lastVerseNum = null; bt.querySelectorAll('.verse[data-verse]').forEach(el => { const v = parseInt(el.getAttribute('data-verse') || '0', 10); if (!isNaN(v)) lastVerseNum = (lastVerseNum == null) ? v : Math.max(lastVerseNum, v); }); if (typeof lastVerseNum === 'number' && !isNaN(lastVerseNum)) { tab._scrollToVerse = lastVerseNum; try { this.autoScrollToSelectedVerse(tab.id); } catch(_) {} } else { const maxScroll = Math.max(0, (ra.scrollHeight - ra.clientHeight) || 0); ra.scrollTop = maxScroll; } } catch (_) {} }; bottomBtn.addEventListener('click', onBottom, { passive: false }); bottomBtn.addEventListener('touchend', onBottom, { passive: false }); bottomBtn._wired = true; } } catch (_) {} // Wire -5 / +5 buttons reliably on restore try { const minus5Btn = chapterHeader.querySelector('.header-nav-btn[data-action="scroll-up-5"]'); const plus5Btn = chapterHeader.querySelector('.header-nav-btn[data-action="scroll-down-5"]'); const onScrollDelta = (delta) => (ev) => { try { ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation && ev.stopImmediatePropagation(); } catch(_) {} 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; } } }); let maxVerses = 1; verses.forEach(el => { const v = parseInt(el.getAttribute('data-verse') || '0', 10); if (!isNaN(v)) maxVerses = Math.max(maxVerses, v); }); const dest = Math.max(1, Math.min(maxVerses, currentVerse + delta)); tab._scrollToVerse = dest; this.autoScrollToSelectedVerse(tab.id); }; if (minus5Btn && !minus5Btn._wired) { minus5Btn.addEventListener('click', onScrollDelta(-5), { passive: false }); minus5Btn.addEventListener('touchend', onScrollDelta(-5), { passive: false }); minus5Btn._wired = true; } if (plus5Btn && !plus5Btn._wired) { plus5Btn.addEventListener('click', onScrollDelta(5), { passive: false }); plus5Btn.addEventListener('touchend', onScrollDelta(5), { passive: false }); plus5Btn._wired = true; } } catch (_) {} } catch (_) {} } // Restore the selectors to match the current tab's state this.populateSelectors(tab); // Ensure header gestures are active for restored content try { this.installHeaderGestures(tab); } catch (_) {} // After restoring content, try restoring the saved scroll position try { this.restoreScrollPosition(tab.id); requestAnimationFrame(() => this.restoreScrollPosition(tab.id)); setTimeout(() => this.restoreScrollPosition(tab.id), 100); } catch (_) {} // Always enable word double-tap listeners for restored chapters setTimeout(() => { addWordClickListeners(tab.id); }, 50); // Annotations will be loaded by setupAnnotationForActiveTab called from switchTab } restoreTab(tabData, makeActive = false) { // Restore a tab that was closed const restoredTab = JSON.parse(JSON.stringify(tabData)); this.tabs.push(restoredTab); // Always make the restored tab active for better reliability this.activeTabId = restoredTab.id; this.renderTabs(); this.renderTabContent(restoredTab); // Always try to restore content for loaded tabs if (restoredTab.loaded && restoredTab.book && restoredTab.chapter && restoredTab.verse) { console.log('[DEBUG] Restoring tab content for:', restoredTab.id, restoredTab); // If we have saved text, restore it immediately if (restoredTab.savedText) { console.log('[DEBUG] Restoring saved text for tab:', restoredTab.id); this.restoreTabContent(restoredTab); } else { // Reload the passage if no saved text console.log('[DEBUG] Reloading passage for restored tab:', restoredTab.id); // Use setTimeout to ensure DOM is ready setTimeout(() => { runAutomaticOperation(() => { this.loadPassage(restoredTab.id); }); }, 100); } // Add a fallback to ensure content is loaded setTimeout(() => { const tabContent = document.querySelector(`#tabContent${restoredTab.id}`); if (tabContent && !tabContent.textContent.trim()) { console.log('[DEBUG] Fallback: Reloading passage for tab:', restoredTab.id); runAutomaticOperation(() => { this.loadPassage(restoredTab.id); }); } }, 500); } else { console.log('[DEBUG] Tab was not loaded, skipping content restoration:', restoredTab.id); } // Setup annotations for the restored tab setupAnnotationForActiveTab(); // Ensure the tab is properly focused const tabElement = document.querySelector(`[data-tab-id="${restoredTab.id}"]`); if (tabElement) { tabElement.classList.add('active'); } } } // Attach to window for global access window.BibleApp = BibleApp; // Note: App initialization is now handled by init.js when imported as a module // --- TOOLBAR LOGIC --- // Tool state let currentTool = 'pen'; // Make currentTool globally accessible for testing window.currentTool = currentTool; // Companion state feedback bridge (UI-driven state sync, separate from command feedback). const companionStateFeedbackCache = { tool: null, lastToolSentAt: 0, penColorHex: null, lastPenColorSentAt: 0, lastActionPulseAt: {} }; function sendCompanionStateFeedback(kind, payload = {}) { try { const body = JSON.stringify({ kind, ...payload, ts: Date.now() }); const url = '/api/osc/state-feedback'; // sendBeacon survives page transitions/reloads better for momentary actions. if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { try { const blob = new Blob([body], { type: 'application/json' }); if (navigator.sendBeacon(url, blob)) return; } catch (_) {} } fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true }).catch(() => {}); } catch (_) {} } function normalizeHexColor6(color) { const raw = String(color || '').trim().toLowerCase(); if (!raw) return null; const hex = raw.startsWith('#') ? raw.slice(1) : raw; if (/^[0-9a-f]{6}$/.test(hex)) return hex; if (/^[0-9a-f]{3}$/.test(hex)) return hex.split('').map((ch) => ch + ch).join(''); return null; } function sendCompanionToolStateFeedback(tool, force = false) { const normalized = ['pen', 'highlight', 'erase', 'select'].includes(tool) ? tool : null; if (!normalized) return; const now = Date.now(); if (!force && companionStateFeedbackCache.tool === normalized && (now - companionStateFeedbackCache.lastToolSentAt) < 120) { return; } companionStateFeedbackCache.tool = normalized; companionStateFeedbackCache.lastToolSentAt = now; sendCompanionStateFeedback('tool_active', { tool: normalized }); } function sendCompanionPenColorStateFeedback(color, force = false) { const normalizedHex = normalizeHexColor6(color); if (!normalizedHex) return; const now = Date.now(); if (!force && companionStateFeedbackCache.penColorHex === normalizedHex && (now - companionStateFeedbackCache.lastPenColorSentAt) < 120) { return; } companionStateFeedbackCache.penColorHex = normalizedHex; companionStateFeedbackCache.lastPenColorSentAt = now; sendCompanionStateFeedback('pen_color_active', { colorHex: normalizedHex }); } /* * Which tab is up, pushed to the server for the one-line OSC status (/bibleapp/feedback/information). * * From the booth you cannot see which tab he has moved to, and you cannot see whether the splash has * gone up over the top of it. This is the half the app knows; server.js holds the rest and joins them. * * Deduped on a signature, so it is free to call from anywhere something might have changed it - a tab * switch, a tab-row rebuild, a passage load - rather than trying to find every place that could. */ const COMPANION_TAB_KIND = { bible: 'Bible', whiteboard: 'Whiteboard', notes: 'Notes', media: 'Media', alpha: 'Alpha', announcements: 'Announcements', graphics: 'Graphics', maps: 'Maps', prayer: 'Prayer', ask: 'Ask' }; let companionActiveTabSignature = null; let companionActiveTabTimer = 0; function sendCompanionActiveTabFeedback() { try { const app = window.bibleApp; if (!app || !Array.isArray(app.tabs)) return; // Count the row as it is actually shown, so "2/5" matches what he is looking at. const shown = app.tabs.filter(t => { try { return app.isTabVisible(t); } catch (_) { return true; } }); const tab = shown.find(t => t.id === app.activeTabId); if (!tab) return; const payload = { index: shown.indexOf(tab) + 1, count: shown.length, // The sync tab is a Bible tab with the ppsync flag; offstage it is a different thing entirely. kind_: tab.ppsync ? 'Sync' : (COMPANION_TAB_KIND[tab.mode] || tab.mode || 'Tab'), // The same thing again as a stable lowercase slug. kind_ is a label for the information line and // could be reworded; `slug` is what an OSC path is built from, so it must never drift. slug: tab.ppsync ? 'sync' : String(tab.mode || 'tab').toLowerCase(), passage: (tab.book && tab.chapter) ? `${tab.book} ${tab.chapter}` : null, translation: tab.translation || null }; const sig = JSON.stringify(payload); if (sig === companionActiveTabSignature) return; companionActiveTabSignature = sig; sendCompanionStateFeedback('active_tab', payload); } catch (_) {} } function scheduleCompanionActiveTabFeedback(delayMs = 200) { try { if (companionActiveTabTimer) clearTimeout(companionActiveTabTimer); companionActiveTabTimer = setTimeout(() => { companionActiveTabTimer = 0; sendCompanionActiveTabFeedback(); }, delayMs); } catch (_) {} } function pulseCompanionActionFeedback(action, durationMs = 850) { const normalizedAction = String(action || '').trim().toLowerCase(); if (!normalizedAction) return; const now = Date.now(); const lastAt = companionStateFeedbackCache.lastActionPulseAt[normalizedAction] || 0; if ((now - lastAt) < 120) return; companionStateFeedbackCache.lastActionPulseAt[normalizedAction] = now; const ms = Number.isFinite(durationMs) ? Math.max(100, Math.min(5000, Math.round(durationMs))) : 850; sendCompanionStateFeedback('action_pulse', { action: normalizedAction, durationMs: ms }); } window.sendCompanionStateFeedback = sendCompanionStateFeedback; window.pulseCompanionActionFeedback = pulseCompanionActionFeedback; window.scheduleCompanionActiveTabFeedback = scheduleCompanionActiveTabFeedback; // Select tool state let selectModeActive = false; // Make select tool functions globally accessible (after declaration) window.selectModeActive = selectModeActive; let activeWordPopups = []; // Track multiple popups // Persistent popup storage const popupHistory = {}; // Save popup data to localStorage function savePopupData(popup) { const resolvedTabId = (() => { try { if (popup && popup.closest) { const ra = popup.closest('.reading-area'); if (ra && ra.id) { const match = ra.id.match(/readingArea(\d+)/); if (match) return Number(match[1]); } } } catch (_) {} try { if (typeof getActiveTabId === 'function') { return getActiveTabId(); } } catch (_) {} return window.bibleApp?.activeTabId || null; })(); const passageKey = getPassageKeyForTabId(resolvedTabId) || getCurrentPassageKey(); if (!passageKey) return; if (!popupHistory[passageKey]) { popupHistory[passageKey] = []; } const popupData = { word: popup.querySelector('.word-info-title').textContent, position: { x: parseFloat(popup.style.left) || 0, y: parseFloat(popup.style.top) || 0, hasBeenDragged: popup._hasBeenDragged || false }, timestamp: Date.now(), id: popup.id, // Persist the rendered word info so restored popups don't need to recompute wordInfo: popup._wordInfo || null }; // Check if popup already exists and update it, otherwise add new const existingIndex = popupHistory[passageKey].findIndex(p => p.word === popupData.word); if (existingIndex >= 0) { popupHistory[passageKey][existingIndex] = popupData; } else { popupHistory[passageKey].push(popupData); } // Save to localStorage try { localStorage.setItem(passageKey + '-popups', JSON.stringify(popupHistory[passageKey])); } catch (e) { console.log('[DEBUG] Failed to save popup data:', e); } } // Load popup data from localStorage function loadPopupData() { const tabId = getActiveTabId(); const passageKey = getPassageKeyForTabId(tabId) || getCurrentPassageKey(); if (!passageKey) return; try { const raw = localStorage.getItem(passageKey + '-popups'); if (raw) { popupHistory[passageKey] = JSON.parse(raw); console.log('[DEBUG] Loaded popup data for passage:', passageKey, popupHistory[passageKey].length, 'popups'); } else { popupHistory[passageKey] = []; } } catch (e) { console.log('[DEBUG] Failed to load popup data:', e); popupHistory[passageKey] = []; } } // Restore all popups for current passage async function restorePopups() { const passageKey = getCurrentPassageKey(); if (!passageKey || !popupHistory[passageKey]) return; const tabId = getActiveTabId(); const bibleText = document.getElementById(`bibleText${tabId}`); const readingArea = document.getElementById(`readingArea${tabId}`); if (!bibleText || !readingArea) return; console.log('[DEBUG] Restoring', popupHistory[passageKey].length, 'popups for passage:', passageKey); for (const popupData of popupHistory[passageKey]) { // Check if popup already exists (visible or hidden) in this reading area const popupKey = popupData.popupKey || `${popupData.word}|${popupData.testament || 'OT'}`; // Check for hidden popups using both attribute formats (data-popup-key and dataset.popupKey) const existingHidden = readingArea.querySelector(`.word-info-popup[data-popup-key="${popupKey}"]`) || Array.from(readingArea.querySelectorAll('.word-info-popup')).find(p => p.dataset && (p.dataset.popupKey === popupKey || p.getAttribute('data-popup-key') === popupKey) ); const existingVisible = activeWordPopups.find(p => p && p.parentElement === readingArea && p.dataset && (p.dataset.popupKey === popupKey || p.getAttribute('data-popup-key') === popupKey) ); if (existingHidden || existingVisible) { // Popup already exists - just show it if hidden const popupToShow = existingHidden || existingVisible; if (popupToShow) { popupToShow.style.display = ''; popupToShow.style.visibility = ''; // Ensure it's in the active popups array if (activeWordPopups.indexOf(popupToShow) === -1) { activeWordPopups.push(popupToShow); } continue; } } // Find the word in the current text const wordSpans = bibleText.querySelectorAll('.clickable-word'); let targetWordSpan = null; for (const span of wordSpans) { if (span.textContent.trim() === popupData.word) { targetWordSpan = span; break; } } if (targetWordSpan) { // Only restore popups that already have the new WLCa/BDBT content let wordInfo = popupData.wordInfo; if (!wordInfo || !wordInfo.wlcaHtml || !wordInfo.bdbtHtml) { continue; } // Create new popup only if it doesn't exist createPopupFromData(wordInfo, targetWordSpan, popupData); } } } // Create popup from saved data function createPopupFromData(wordInfo, wordSpan, popupData) { const popup = document.createElement('div'); popup.className = 'word-info-popup'; popup.id = popupData.id; let isNT = false; try { isNT = !!wordInfo?.isNT || hasGreekText(wordInfo?.sourceHtml || wordInfo?.wlcaHtml) || (() => { const popupBook = getPopupBookName(wordInfo); const popupBookNum = getBookNumberForName(popupBook); return !!popupBookNum && popupBookNum >= 40; })(); if (isNT) { wordInfo.sourceLabel = 'NTGT'; wordInfo.sourceLang = 'el'; wordInfo.sourceDir = 'ltr'; wordInfo.sourceClass = 'greek'; wordInfo.dictionaryLabel = "Strong's Greek"; wordInfo.dictionaryClass = 'greek bdbt'; if (wordInfo.dictionaryHtml) { wordInfo.dictionaryHtml = wordInfo.dictionaryHtml .replace(/BDBT/gi, "Strong's Greek") .replace(/No Greek dictionary entry found/gi, 'No Strong\'s Greek entry found'); } if (!wordInfo.dictionaryHtml) { wordInfo.dictionaryHtml = '
No Greek dictionary entry found.
'; } } } catch (_) {} const title = 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' : 'en'); 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') && !isNT; const showNTNTGT = isPopupSectionVisible('nt_ntgt') && isNT; 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/); // Show dictionary section if: // 1. Master toggle is ON, OR // 2. Dictionary has content (meaning individual field toggles are working) const showOTBDBT = !isNT && (otBdbtMasterToggle || dictionaryHasContent); const showNTStrongsGreek = isNT && (ntStrongsMasterToggle || dictionaryHasContent); // Build popup HTML based on visibility settings let popupContent = ''; let hasContent = false; // OT sections if (!isNT) { 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 (isNT) { 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; } } popup.innerHTML = `
${showTitle ? `

${escapeHtml(title)}

` : '

'}
${popupContent || '
No information available
'}
`; normalizePopupTestament(popup); normalizePopupTestament(popup); // Find the reading area for the current tab const readingArea = wordSpan.closest('.reading-area'); popup.style.position = 'absolute'; // Wider layout to reduce excessive height popup.style.minWidth = '640px'; popup.style.maxWidth = '800px'; popup.style.zIndex = 30000 + activeWordPopups.length; // Restore saved position with scroll-aware positioning if (popupData.position.hasBeenDragged) { // For dragged popups, use saved position relative to reading area popup.style.left = popupData.position.x + 'px'; popup.style.top = popupData.position.y + 'px'; popup._hasBeenDragged = true; } // Append popup to reading area so it scrolls with the text readingArea.appendChild(popup); // A card the operator never dragged gets the same centred / under-the-line placement as a fresh // one. (Dragged cards keep their saved x/y, set above.) if (!popup._hasBeenDragged) positionWordPopup(popup, wordSpan, readingArea, 0); // Store which tab this popup belongs to const activeTabId = getActiveTabId(); popup.dataset.tabId = activeTabId || ''; // Make popup draggable (relative to reading area) makePopupDraggable(popup, readingArea); // Remember the info used to render this popup for persistence popup._wordInfo = wordInfo; // Add to active popups activeWordPopups.push(popup); // Show popup setTimeout(() => { popup.classList.add('show'); }, 10); // Set up close handler const closeBtn = popup.querySelector('.word-info-close'); if (closeBtn) { const onClose = (e) => { if (window._popupDragging) return; try { e.preventDefault(); e.stopPropagation(); } catch(_) {} closeSpecificWordPopup(popup); }; closeBtn.addEventListener('click', onClose); closeBtn.addEventListener('touchend', onClose, { passive: false }); closeBtn.addEventListener('pointerup', onClose, { passive: true }); } } // Clear popup data for current passage function clearPopupData() { const passageKey = getCurrentPassageKey(); if (passageKey) { popupHistory[passageKey] = []; localStorage.removeItem(passageKey + '-popups'); } } // DOM refs - will be initialized after DOM is ready let penBtn, highlightBtn, eraseBtn, selectBtn, penColorDropdown, highlighterColorDropdown, penSizeSliderPopup, penSizeSlider; // Guard to prevent multiple initializations let toolUIInitialized = false; let initializationAttempts = 0; const MAX_INITIALIZATION_ATTEMPTS = 10; // Initialize DOM references and event listeners // NEW: Global system health check function function verifySystemHealth() { if (window.bibleApp && typeof window.bibleApp.verifyAndRepairSystemState === 'function') { return window.bibleApp.verifyAndRepairSystemState(); } else { console.log('[SYSTEM CHECK] BibleApp not available for health check'); return false; } } // Make system health check globally available window.verifySystemHealth = verifySystemHealth; // NEW: Global function to immediately fix select tool cursor window.fixSelectToolCursor = function() { console.log('[GLOBAL FIX] Forcing select tool cursor immediately'); // Apply cursor universally regardless of conditions let selectToolStyle = document.getElementById('select-tool-cursor-override'); if (!selectToolStyle) { selectToolStyle = document.createElement('style'); selectToolStyle.id = 'select-tool-cursor-override'; document.head.appendChild(selectToolStyle); } selectToolStyle.textContent = ` .canvas-media-item, .canvas-media-item * { cursor: move !important; pointer-events: auto !important; } .media-drawing-canvas { pointer-events: none !important; z-index: 5000 !important; } `; // Force apply to all existing media items const mediaItems = document.querySelectorAll('.canvas-media-item'); mediaItems.forEach(item => { item.style.setProperty('cursor', 'move', 'important'); item.style.setProperty('pointer-events', 'auto', 'important'); // ?? LAYER FIX: Don't override z-index - let layer manager handle it // Only set if not already managed by layer system if (!item.style.zIndex || parseInt(item.style.zIndex) < 100) { item.style.setProperty('z-index', '100', 'important'); } const childElements = item.querySelectorAll('*'); childElements.forEach(child => { child.style.setProperty('cursor', 'move', 'important'); child.style.setProperty('pointer-events', 'auto', 'important'); }); }); console.log('[GLOBAL FIX] Applied select cursor to', mediaItems.length, 'media items'); // Also try the targeted function if available if (window.bibleApp && typeof window.bibleApp.applySelectToolCursorImmediate === 'function') { window.bibleApp.applySelectToolCursorImmediate(); console.log('[GLOBAL FIX] Also applied targeted cursor fix'); } return true; }; // NEW: Seamless tool switching function to prevent glitches function handleSeamlessToolSwitch(toolType) { console.log('[SEAMLESS] Coordinated tool switch to:', toolType); // Remove global CSS overrides that might conflict const selectToolStyle = document.getElementById('select-tool-cursor-override'); if (selectToolStyle && toolType !== 'select') { selectToolStyle.remove(); console.log('[SEAMLESS] Removed select tool CSS override'); } // For ANY tab mode, apply select tool cursor if switching to select if (toolType === 'select') { console.log('[SEAMLESS] Applying select tool cursor for ANY tab mode'); // Apply cursor universally let selectToolStyle = document.getElementById('select-tool-cursor-override'); if (!selectToolStyle) { selectToolStyle = document.createElement('style'); selectToolStyle.id = 'select-tool-cursor-override'; document.head.appendChild(selectToolStyle); } selectToolStyle.textContent = ` .canvas-media-item, .canvas-media-item * { cursor: move !important; pointer-events: auto !important; transition: z-index 0.1s ease; } .media-drawing-canvas { pointer-events: none !important; z-index: 5000 !important; transition: z-index 0.1s ease; } `; // Apply to all existing media items const mediaItems = document.querySelectorAll('.canvas-media-item'); mediaItems.forEach(item => { item.style.transition = 'z-index 0.1s ease'; item.style.setProperty('cursor', 'move', 'important'); item.style.setProperty('pointer-events', 'auto', 'important'); item.style.setProperty('z-index', '100', 'important'); // Keep below annotation canvas const childElements = item.querySelectorAll('*'); childElements.forEach(child => { child.style.setProperty('cursor', 'move', 'important'); child.style.setProperty('pointer-events', 'auto', 'important'); }); }); console.log('[SEAMLESS] Applied select cursor to', mediaItems.length, 'media items universally'); // Also call the targeted function if available if (window.bibleApp && typeof window.bibleApp.applySelectToolCursorImmediate === 'function') { window.bibleApp.applySelectToolCursorImmediate(); } // CRITICAL: For Bible and Alpha modes, ensure word selection is properly enabled const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); if (activeTab && (activeTab.mode === 'bible' || activeTab.mode === 'alpha') && window.selectModeActive) { console.log('[SEAMLESS] Re-enabling word selection for Bible/Alpha mode'); // Small delay to ensure proper coordination setTimeout(() => { if (window.enableWordSelection && window.selectModeActive) { window.enableWordSelection(); } }, 15); } else if (activeTab && activeTab.mode === 'media' && window.selectModeActive) { // CRITICAL: For media mode, ensure media items are properly interactive console.log('[SEAMLESS] Simple media mode select tool setup'); // Just update canvas pointer events - no aggressive manipulation updateCanvasPointerEvents(); } } // For media mode, ensure drawing canvas is properly set up 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(activeTab.id); } // ?? ENHANCED: Update canvas styling for the new tool (without recreating canvas) const drawingCanvas = document.querySelector('.media-drawing-canvas'); if (drawingCanvas && window.localMediaHandler && typeof window.localMediaHandler.updateCanvasStyling === 'function') { window.localMediaHandler.updateCanvasStyling(drawingCanvas); console.log('[SEAMLESS] ?? Updated canvas styling for tool:', toolType); } // If coming from select tool, reattach drawing listeners explicitly if (toolType !== 'select' && drawingCanvas) { // Fully recover from select mode: ensure pointer-events and listeners are active drawingCanvas._selectModeDisabled = false; drawingCanvas.style.setProperty('pointer-events', 'auto', 'important'); if (window.localMediaHandler && typeof window.localMediaHandler.setupMediaDrawingListeners === 'function') { window.localMediaHandler.setupMediaDrawingListeners(drawingCanvas, activeTab.id); console.log('[SEAMLESS] Reattached drawing listeners after leaving select'); } if (window.localMediaHandler && typeof window.localMediaHandler.updateCanvasStyling === 'function') { window.localMediaHandler.updateCanvasStyling(drawingCanvas); } } // Coordinated update for media interactions setTimeout(() => { if (window.localMediaHandler && window.localMediaHandler.refreshMediaInteractions) { window.localMediaHandler.refreshMediaInteractions(); console.log('[SEAMLESS] Refreshed media interactions for', toolType); } // ?? ENHANCED: Ensure annotations stay visible after tool switch if (window.mediaLayerManager) { window.mediaLayerManager.ensureAnnotationsOnTop(); console.log('[SEAMLESS] ?? Ensured annotations visible after tool switch'); } }, 5); // Very short delay to prevent conflicts } // Gentle style reset for touch devices - preserve colors if (window.isTouchDevice) { const buttons = [window.penBtn, window.highlightBtn, window.eraseBtn, window.selectBtn].filter(btn => btn); buttons.forEach(btn => { // Only reset hover/active states, preserve colors btn.style.removeProperty('background'); btn.style.removeProperty('background-color'); btn.style.removeProperty('border'); btn.style.removeProperty('border-color'); btn.style.removeProperty('box-shadow'); btn.style.removeProperty('transform'); btn.style.removeProperty('opacity'); btn.style.removeProperty('filter'); }); } // Unified tool UI update setTimeout(() => { if (window.updateToolUI) { window.updateToolUI(); } // Update canvas pointer events consistently updateCanvasPointerEvents(); console.log('[SEAMLESS] Tool switch complete for', toolType); }, 8); // Slight delay to ensure all updates are coordinated } // Centralized exclusive tool activation for gestures and programmatic switches function forceSetDrawingTool(toolType) { try { const normalized = (toolType === 'pen' || toolType === 'highlight' || toolType === 'erase' || toolType === 'select') ? toolType : 'pen'; const previous = currentTool; if (normalized === 'select') { currentTool = 'select'; window.currentTool = 'select'; if (!selectModeActive) { selectModeActive = true; window.selectModeActive = true; try { enableWordSelection(); } catch (_) {} } handleSeamlessToolSwitch('select'); } else { if (selectModeActive) { try { disableWordSelection(); } catch (_) {} selectModeActive = false; window.selectModeActive = false; try { const s = document.getElementById('select-tool-cursor-override'); if (s) s.remove(); } catch (_) {} try { const m = document.getElementById('media-select-tool-override'); if (m) m.remove(); } catch (_) {} } currentTool = normalized; window.currentTool = normalized; handleSeamlessToolSwitch(normalized); } if (typeof updateToolUI === 'function') updateToolUI(); if (typeof updateCanvasPointerEvents === 'function') updateCanvasPointerEvents(); try { pushAction(createAction(ACTION_TYPES.TOOL_CHANGE, { from: previous, to: normalized })); } catch (_) {} } catch (_) {} } window.forceSetDrawingTool = forceSetDrawingTool; function initializeToolUI() { // Prevent multiple initializations if (toolUIInitialized) { console.log('Tool UI already initialized, skipping duplicate initialization'); return; } // Track initialization attempts initializationAttempts++; if (initializationAttempts > MAX_INITIALIZATION_ATTEMPTS) { console.error('Failed to initialize Tool UI after maximum attempts. Please check if required DOM elements exist.'); return; } // Get DOM elements and assign to both local and global scope penBtn = window.penBtn = document.getElementById('penToolBtn'); highlightBtn = window.highlightBtn = document.getElementById('highlightToolBtn'); eraseBtn = window.eraseBtn = document.querySelector('.tool-icon[data-tool="erase"]'); selectBtn = window.selectBtn = document.getElementById('selectToolBtn'); penColorDropdown = window.penColorDropdown = document.getElementById('penColorDropdown'); highlighterColorDropdown = window.highlighterColorDropdown = document.getElementById('highlighterColorDropdown'); penSizeSliderPopup = window.penSizeSliderPopup = document.getElementById('penSizeSliderPopup'); penSizeSlider = window.penSizeSlider = document.getElementById('penSizeSlider'); // Check if elements exist if (!penBtn || !highlightBtn || !penColorDropdown || !highlighterColorDropdown) { console.error(`Tool UI elements not found, attempt ${initializationAttempts}/${MAX_INITIALIZATION_ATTEMPTS}, retrying in 100ms`); setTimeout(initializeToolUI, 100); return; } console.log('Tool UI elements found, setting up event listeners'); // Mark as initialized toolUIInitialized = true; // 3-finger tool toggle removed // Is this ink too dark to read as a ring/glyph on the dark grey (#333) tool buttons? The Notes page // is white paper, so its pen is black — which drew a black ring on a dark button at 1.66:1 contrast // and looked like no outline at all. Shared with the floating dock so both judge it the same way. window.isDarkInk = function isDarkInk(color) { try { const c = String(color || '').trim(); let r, g, b, m; if (c[0] === '#') { if (c.length === 4) { r = parseInt(c[1] + c[1], 16); g = parseInt(c[2] + c[2], 16); b = parseInt(c[3] + c[3], 16); } else { r = parseInt(c.slice(1, 3), 16); g = parseInt(c.slice(3, 5), 16); b = parseInt(c.slice(5, 7), 16); } } else if ((m = c.match(/(\d+(?:\.\d+)?)/g)) && m.length >= 3) { r = +m[0]; g = +m[1]; b = +m[2]; } else return false; if (isNaN(r) || isNaN(g) || isNaN(b)) return false; const lin = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; // The buttons sit at ~0.033 luminance; below ~0.10 there is under 2:1 contrast against them. return (0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)) < 0.10; } catch (_) { return false; } }; // Helper: update tool button visuals with aggressive touch device support window.updateToolUI = function updateToolUI() { const toolButtons = [penBtn, highlightBtn, eraseBtn, selectBtn].filter(Boolean); // Gentle style reset - preserve colors toolButtons.forEach(btn => { btn.classList.remove('selected'); if (isTouchDevice) { // Only clear hover/active states, preserve colors btn.style.removeProperty('background'); btn.style.removeProperty('background-color'); btn.style.removeProperty('border'); btn.style.removeProperty('border-color'); btn.style.removeProperty('box-shadow'); btn.style.removeProperty('transform'); btn.style.removeProperty('opacity'); btn.style.removeProperty('filter'); // Don't force transparent - let CSS handle it naturally } }); // Force browser layout recalculation if (isTouchDevice) { toolButtons.forEach(btn => btn.offsetHeight); } // Set colors FIRST - most important step penBtn.style.setProperty('--pen-color', penColor); penBtn.style.color = penColor; highlightBtn.style.setProperty('--highlighter-color', highlighterColor); highlightBtn.style.color = highlighterColor; // The selected ring is drawn in the ink colour, so dark ink needs a light chip behind it or the // selection is invisible (see isDarkInk). try { penBtn.classList.toggle('dark-ink', window.isDarkInk(penColor)); highlightBtn.classList.toggle('dark-ink', window.isDarkInk(highlighterColor)); } catch (_) {} eraseBtn.style.color = '#ffb6c1'; if (selectBtn) { selectBtn.style.color = '#4a9eff'; } // Apply selected state AFTER colors are set if (currentTool === 'pen') { penBtn.classList.add('selected'); // Always force pen color, not just on touch devices penBtn.style.color = penColor + ' !important'; if (isTouchDevice) { penBtn.style.background = '#333'; penBtn.style.setProperty('color', penColor, 'important'); } } else if (currentTool === 'highlight') { highlightBtn.classList.add('selected'); // Always force highlighter color, not just on touch devices highlightBtn.style.color = highlighterColor + ' !important'; if (isTouchDevice) { highlightBtn.style.background = '#333'; highlightBtn.style.border = `2px solid ${highlighterColor}`; highlightBtn.style.setProperty('color', highlighterColor, 'important'); highlightBtn.style.boxShadow = `0 0 0 2px ${highlighterColor}, 0 0 8px rgba(255, 255, 0, 0.5)`; } } else if (currentTool === 'erase') { eraseBtn.classList.add('selected'); // Always force eraser color, not just on touch devices eraseBtn.style.color = '#ffb6c1 !important'; if (isTouchDevice) { eraseBtn.style.background = '#333'; eraseBtn.style.border = '2px solid #ffb6c1'; eraseBtn.style.setProperty('color', '#ffb6c1', 'important'); eraseBtn.style.boxShadow = '0 0 0 2px #ffb6c1, 0 0 8px rgba(255, 182, 193, 0.5)'; } } else if (currentTool === 'select' && selectBtn) { selectBtn.classList.add('selected'); // Always force select color, not just on touch devices selectBtn.style.color = '#4a9eff !important'; if (isTouchDevice) { selectBtn.style.background = '#333'; selectBtn.style.border = '2px solid #4a9eff'; selectBtn.style.setProperty('color', '#4a9eff', 'important'); selectBtn.style.boxShadow = '0 0 0 2px #4a9eff, 0 0 8px rgba(74, 158, 255, 0.5)'; } } // Final safety check - re-apply colors to ensure they stick (for ALL devices) setTimeout(() => { // Force colors with important priority penBtn.style.setProperty('color', penColor, 'important'); highlightBtn.style.setProperty('color', highlighterColor, 'important'); eraseBtn.style.setProperty('color', '#ffb6c1', 'important'); if (selectBtn) { selectBtn.style.setProperty('color', '#4a9eff', 'important'); } // Ensure selected tool has the right color if (currentTool === 'pen') { penBtn.style.setProperty('color', penColor, 'important'); } else if (currentTool === 'highlight') { highlightBtn.style.setProperty('color', highlighterColor, 'important'); } else if (currentTool === 'erase') { eraseBtn.style.setProperty('color', '#ffb6c1', 'important'); } else if (currentTool === 'select' && selectBtn) { selectBtn.style.setProperty('color', '#4a9eff', 'important'); } }, 10); // Update compact tool indicator try { const indicator = document.getElementById('toolIndicator'); if (indicator) { const icon = indicator.querySelector('i'); const label = indicator.querySelector('span') || (() => { const s = document.createElement('span'); indicator.appendChild(s); return s; })(); if (currentTool === 'pen') { icon.className = 'fas fa-pen'; icon.style.color = penColor; label.textContent = 'Pen'; label.style.color = penColor; } else if (currentTool === 'highlight') { icon.className = 'fas fa-highlighter'; icon.style.color = highlighterColor; label.textContent = 'Highlighter'; label.style.color = highlighterColor; } else if (currentTool === 'erase') { icon.className = 'fas fa-eraser'; icon.style.color = '#ffb6c1'; label.textContent = 'Eraser'; label.style.color = '#ffb6c1'; } else if (currentTool === 'select') { icon.className = 'fas fa-mouse-pointer'; icon.style.color = '#4a9eff'; label.textContent = 'Select'; label.style.color = '#4a9eff'; } } } catch (_) {} // Sync global variable for testing window.currentTool = currentTool; sendCompanionToolStateFeedback(currentTool); sendCompanionPenColorStateFeedback(penColor); // Ensure media drawing canvas is properly set up for the current tool 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); } }); // Also update media items pointer events const isDrawingTool = currentTool === 'pen' || currentTool === 'highlight' || currentTool === 'erase'; const isSelectTool = currentTool === 'select'; if (window.localMediaHandler && window.localMediaHandler.updateMediaItemsPointerEvents) { window.localMediaHandler.updateMediaItemsPointerEvents(isDrawingTool, isSelectTool); } // Add CSS rule to ensure media items are non-interactive when drawing updateMediaDrawingCSS(isDrawingTool); } // Also call the adjustDrawingCanvasZIndex function if it exists if (window.adjustDrawingCanvasZIndex && typeof window.adjustDrawingCanvasZIndex === 'function') { window.adjustDrawingCanvasZIndex(); } // UNIVERSAL FIX: If select tool is active, apply cursor regardless of tab mode setTimeout(() => { if (currentTool === 'select' && selectModeActive) { console.log('[POST-UPDATEUI] Select tool is active - applying cursor for ANY tab mode'); // Apply cursor to ALL media items regardless of tab mode let selectToolStyle = document.getElementById('select-tool-cursor-override'); if (!selectToolStyle) { selectToolStyle = document.createElement('style'); selectToolStyle.id = 'select-tool-cursor-override'; document.head.appendChild(selectToolStyle); } selectToolStyle.textContent = ` .canvas-media-item, .canvas-media-item * { cursor: move !important; pointer-events: auto !important; } .media-drawing-canvas { pointer-events: none !important; } `; // Force apply to existing media items const mediaItems = document.querySelectorAll('.canvas-media-item'); mediaItems.forEach(item => { item.style.setProperty('cursor', 'move', 'important'); item.style.setProperty('pointer-events', 'auto', 'important'); const childElements = item.querySelectorAll('*'); childElements.forEach(child => { child.style.setProperty('cursor', 'move', 'important'); child.style.setProperty('pointer-events', 'auto', 'important'); }); }); console.log('[POST-UPDATEUI] Applied select cursor to', mediaItems.length, 'media items'); // Also call the targeted function if (window.bibleApp && typeof window.bibleApp.applySelectToolCursorImmediate === 'function') { window.bibleApp.applySelectToolCursorImmediate(); } } }, 1); // Immediate but after DOM update console.log('Tool UI updated - current tool:', currentTool, 'pen:', penColor, 'highlight:', highlighterColor); // Ensure Bible drawing is ready after any tool UI update try { ensureBibleDrawingReady(); } catch (_) {} } // Function to update media drawing CSS function updateMediaDrawingCSS(isDrawingTool) { // Remove existing CSS rule if it exists const existingStyle = document.getElementById('media-drawing-css'); if (existingStyle) { existingStyle.remove(); } if (isDrawingTool) { // Add CSS rule to completely disable media item interactions const style = document.createElement('style'); style.id = 'media-drawing-css'; style.textContent = ` .canvas-media-item { pointer-events: none !important; cursor: default !important; user-select: none !important; -webkit-user-select: none !important; -moz-user-select: none !important; -ms-user-select: none !important; } .canvas-media-item * { pointer-events: none !important; } /* Ensure annotation canvas ALWAYS stays above media */ .media-drawing-canvas { pointer-events: auto !important; z-index: 5000 !important; /* Use precise crosshair only during active draw; default to default cursor to avoid ghost crosshair on extra touches */ cursor: default !important; display: block !important; } .canvas-content { padding: 0 !important; } `; document.head.appendChild(style); console.log('[DEBUG] Added CSS rule to disable media item interactions and ensure drawing canvas is active (z-index 5000)'); } } // Helper: close all dropdowns function closeDropdowns() { penColorDropdown.classList.remove('show'); highlighterColorDropdown.classList.remove('show'); penSizeSliderPopup.classList.remove('show'); } // Did the tap being handled right now come off the back of putting the laser/spotlight away? Those // overlays never move currentTool, so without this the first tap back onto the pen looks like a second // tap on an already-active pen and pops the colour swatches open. Consumed on read, so it can only // ever affect the single tap that dismissed the overlay. function tapDismissedOverlay() { try { const t = window.__ptLeftOverlayAt; if (!t) return false; window.__ptLeftOverlayAt = 0; return (Date.now() - t) < 1200; } catch (_) { return false; } } // Tool selection logic with enhanced touch support function handleToolSelection(toolType, e) { console.log(`Main App: ${toolType} button selected, currentTool:`, currentTool); // CRITICAL: Stop event propagation if (e) { e.preventDefault(); e.stopPropagation(); } // Ignore accidental clicks immediately after opening the tool rail try { if (window.toolRailInputGuardUntil && Date.now() < window.toolRailInputGuardUntil) { console.log('[GUARD] Ignoring tool selection due to recent rail open'); return; } } catch (_) {} // Read once, before any branch, so the flag is consumed exactly once per tap. const dismissedOverlay = tapDismissedOverlay(); if (toolType === 'pen') { if (currentTool === 'pen' && !dismissedOverlay) { penColorDropdown.classList.toggle('show'); highlighterColorDropdown.classList.remove('show'); penSizeSliderPopup.classList.remove('show'); highlighterColorDropdown.classList.remove('show'); console.log('Main App: Toggling pen color dropdown'); } else { // Don't track tool change action - UI state changes shouldn't be undoable currentTool = 'pen'; window.currentTool = currentTool; // Sync global closeDropdowns(); // Disable select mode when switching to other tools if (selectModeActive) { disableWordSelection(); selectModeActive = false; window.selectModeActive = false; // CRITICAL: Reset global variable // CLEANUP: Remove global CSS cursor override when leaving select tool const selectToolStyle = document.getElementById('select-tool-cursor-override'); if (selectToolStyle) { selectToolStyle.remove(); console.log('[CLEANUP] Removed global CSS cursor override'); } // CLEANUP: Remove media select tool CSS override when leaving select tool const mediaSelectStyle = document.getElementById('media-select-tool-override'); if (mediaSelectStyle) { mediaSelectStyle.remove(); console.log('[CLEANUP] Removed media select tool CSS override'); } // SMOOTH CLEANUP: Gentle restoration without flickering const drawingCanvases = document.querySelectorAll('.media-drawing-canvas'); drawingCanvases.forEach(canvas => { if (canvas._selectModeDisabled) { // SMOOTH: Add transition before changing properties canvas.style.transition = 'z-index 0.1s ease'; // Re-enable pointer events and restore high z-index so annotations stay on top canvas.style.setProperty('pointer-events', 'auto', 'important'); canvas.style.setProperty('z-index', '5000', 'important'); // Clear the disabled flag canvas._selectModeDisabled = false; // BATCHED: Delay event listener setup to avoid interference setTimeout(() => { const drawingCanvas = document.querySelector('.media-drawing-canvas'); if (drawingCanvas && window.localMediaHandler && typeof window.localMediaHandler.updateCanvasStyling === 'function') { const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); if (activeTab && window.currentTool !== 'select') { // Only if still not in select mode window.localMediaHandler.updateCanvasStyling(drawingCanvas); } } }, 150); // After transition completes console.log('[SMOOTH CLEANUP] Re-enabled drawing canvas smoothly'); } }); // SMOOTH CLEANUP: Reset media items z-index gently const mediaItems = document.querySelectorAll('.canvas-media-item'); mediaItems.forEach(item => { item.style.transition = 'z-index 0.1s ease'; item.style.setProperty('z-index', '100', 'important'); console.log('[SMOOTH CLEANUP] Reset media item z-index smoothly'); }); console.log('[SMOOTH CLEANUP] Complete smooth cleanup without flickering'); // CRITICAL: Stop the select tool monitor when leaving select tool if (window.selectToolMonitor) { clearInterval(window.selectToolMonitor); window.selectToolMonitor = null; console.log('[CLEANUP] Stopped select tool monitor'); } } // SEAMLESS: Coordinated tool switching for pen handleSeamlessToolSwitch('pen'); console.log('Main App: Switching to pen tool'); // HARDENED: If active tab is media, force a quick canvas re-setup (no flicker) try { const active = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); if (active && active.mode === 'media' && window.localMediaHandler) { if (typeof window.localMediaHandler.ensureDrawingCanvasExists === 'function') { window.localMediaHandler.ensureDrawingCanvasExists(active.id); } if (typeof window.localMediaHandler.refreshMediaInteractions === 'function') { window.localMediaHandler.refreshMediaInteractions(); } updateMediaDrawingCSS(true); if (window.updateCanvasPointerEvents) { window.updateCanvasPointerEvents(); } console.log('[RECOVER] Media pen re-activation sequence completed'); } } catch (err) { console.warn('[RECOVER] Pen re-activation error:', err); } } } else if (toolType === 'highlight') { if (currentTool === 'highlight' && !dismissedOverlay) { highlighterColorDropdown.classList.toggle('show'); penColorDropdown.classList.remove('show'); console.log('Main App: Toggling highlighter color dropdown'); } else { // Don't track tool change action - UI state changes shouldn't be undoable currentTool = 'highlight'; window.currentTool = currentTool; // Sync global closeDropdowns(); // Disable select mode when switching to other tools if (selectModeActive) { disableWordSelection(); selectModeActive = false; window.selectModeActive = false; // CRITICAL: Reset global variable // CLEANUP: Remove global CSS cursor override when leaving select tool const selectToolStyle = document.getElementById('select-tool-cursor-override'); if (selectToolStyle) { selectToolStyle.remove(); console.log('[CLEANUP] Removed global CSS cursor override'); } // CLEANUP: Remove media select tool CSS override when leaving select tool const mediaSelectStyle = document.getElementById('media-select-tool-override'); if (mediaSelectStyle) { mediaSelectStyle.remove(); console.log('[CLEANUP] Removed media select tool CSS override'); } // SMOOTH CLEANUP: Gentle restoration without flickering const drawingCanvases = document.querySelectorAll('.media-drawing-canvas'); drawingCanvases.forEach(canvas => { if (canvas._selectModeDisabled) { // SMOOTH: Add transition before changing properties canvas.style.transition = 'z-index 0.1s ease'; // Re-enable pointer events and restore high z-index so annotations stay on top canvas.style.setProperty('pointer-events', 'auto', 'important'); canvas.style.setProperty('z-index', '5000', 'important'); // Clear the disabled flag canvas._selectModeDisabled = false; // BATCHED: Delay event listener setup to avoid interference setTimeout(() => { const drawingCanvas = document.querySelector('.media-drawing-canvas'); if (drawingCanvas && window.localMediaHandler && typeof window.localMediaHandler.updateCanvasStyling === 'function') { const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); if (activeTab && window.currentTool !== 'select') { // Only if still not in select mode window.localMediaHandler.updateCanvasStyling(drawingCanvas); } } }, 150); // After transition completes console.log('[SMOOTH CLEANUP] Re-enabled drawing canvas smoothly'); } }); // SMOOTH CLEANUP: Reset media items z-index gently const mediaItems = document.querySelectorAll('.canvas-media-item'); mediaItems.forEach(item => { item.style.transition = 'z-index 0.1s ease'; item.style.setProperty('z-index', '100', 'important'); console.log('[SMOOTH CLEANUP] Reset media item z-index smoothly'); }); console.log('[SMOOTH CLEANUP] Complete smooth cleanup without flickering'); // CRITICAL: Stop the select tool monitor when leaving select tool if (window.selectToolMonitor) { clearInterval(window.selectToolMonitor); window.selectToolMonitor = null; console.log('[CLEANUP] Stopped select tool monitor'); } } // SEAMLESS: Coordinated tool switching for highlight handleSeamlessToolSwitch('highlight'); console.log('Main App: Switching to highlight tool'); // HARDENED: If active tab is media, force a quick canvas re-setup (no flicker) try { const active = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); if (active && active.mode === 'media' && window.localMediaHandler) { if (typeof window.localMediaHandler.ensureDrawingCanvasExists === 'function') { window.localMediaHandler.ensureDrawingCanvasExists(active.id); } if (typeof window.localMediaHandler.refreshMediaInteractions === 'function') { window.localMediaHandler.refreshMediaInteractions(); } updateMediaDrawingCSS(true); console.log('[RECOVER] Media highlighter re-activation sequence completed'); } } catch (err) { console.warn('[RECOVER] Highlighter re-activation error:', err); } } } else if (toolType === 'erase') { // Don't track tool change action - UI state changes shouldn't be undoable currentTool = 'erase'; window.currentTool = currentTool; // Sync global closeDropdowns(); // Disable select mode when switching to other tools if (selectModeActive) { disableWordSelection(); selectModeActive = false; window.selectModeActive = false; // CRITICAL: Reset global variable // CLEANUP: Remove global CSS cursor override when leaving select tool const selectToolStyle = document.getElementById('select-tool-cursor-override'); if (selectToolStyle) { selectToolStyle.remove(); console.log('[CLEANUP] Removed global CSS cursor override'); } // CLEANUP: Remove media select tool CSS override when leaving select tool const mediaSelectStyle = document.getElementById('media-select-tool-override'); if (mediaSelectStyle) { mediaSelectStyle.remove(); console.log('[CLEANUP] Removed media select tool CSS override'); } } // SEAMLESS: Coordinated tool switching for erase handleSeamlessToolSwitch('erase'); console.log('Main App: Switching to erase tool'); try { const active = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); if (active && active.mode === 'media' && window.localMediaHandler) { const drawingCanvas = document.querySelector('.media-drawing-canvas'); if (drawingCanvas) { drawingCanvas._selectModeDisabled = false; drawingCanvas.style.setProperty('pointer-events', 'auto', 'important'); if (typeof window.localMediaHandler.setupMediaDrawingListeners === 'function') { window.localMediaHandler.setupMediaDrawingListeners(drawingCanvas, active.id); } if (typeof window.localMediaHandler.updateCanvasStyling === 'function') { window.localMediaHandler.updateCanvasStyling(drawingCanvas); } } if (typeof window.localMediaHandler.refreshMediaInteractions === 'function') { window.localMediaHandler.refreshMediaInteractions(); } updateMediaDrawingCSS(true); if (window.updateCanvasPointerEvents) { window.updateCanvasPointerEvents(); } console.log('[RECOVER] Media eraser re-activation sequence completed'); } } catch (err) { console.warn('[RECOVER] Eraser re-activation error:', err); } } else if (toolType === 'select') { // Don't track tool change action - UI state changes shouldn't be undoable currentTool = 'select'; window.currentTool = currentTool; // Sync global immediately closeDropdowns(); // CRITICAL: Set select mode active IMMEDIATELY - no delays selectModeActive = true; window.selectModeActive = true; console.log('[SELECT TOOL] IMMEDIATE activation - currentTool:', currentTool, 'selectModeActive:', selectModeActive); // Gentle style reset for touch devices - preserve colors if (isTouchDevice) { [penBtn, highlightBtn, eraseBtn, selectBtn].filter(Boolean).forEach(btn => { // Only reset hover/active states, preserve colors btn.style.removeProperty('background'); btn.style.removeProperty('background-color'); btn.style.removeProperty('border'); btn.style.removeProperty('border-color'); btn.style.removeProperty('box-shadow'); btn.style.removeProperty('transform'); btn.style.removeProperty('opacity'); btn.style.removeProperty('filter'); }); } updateToolUI(); console.log('Main App: Switching to select tool - IMMEDIATE MODE'); // Get active tab const activeTab = window.bibleApp.tabs.find(t => t.id === window.bibleApp.activeTabId); // CRITICAL: IMMEDIATE activation for BOTH Bible, Alpha, and Media modes if (activeTab && (activeTab.mode === 'bible' || activeTab.mode === 'alpha')) { console.log('[SELECT TOOL] IMMEDIATE Bible/Alpha mode activation'); // Enable word selection IMMEDIATELY - no timeouts enableWordSelection(); // Also call setup function immediately if (window.setupAnnotationForActiveTab) { window.setupAnnotationForActiveTab(); } } else if (activeTab && activeTab.mode === 'media') { console.log('[SELECT TOOL] Media mode activation - COMPLETE EVENT REMOVAL'); // CRITICAL: Actually remove all event listeners from drawing canvas const drawingCanvases = document.querySelectorAll('.media-drawing-canvas'); drawingCanvases.forEach(canvas => { console.log('[SELECT TOOL] Removing ALL event listeners from drawing canvas'); // Remove all mouse event listeners if (canvas._mouseDownHandler) { canvas.removeEventListener('mousedown', canvas._mouseDownHandler); } if (canvas._mouseMoveHandler) { canvas.removeEventListener('mousemove', canvas._mouseMoveHandler); } if (canvas._mouseUpHandler) { canvas.removeEventListener('mouseup', canvas._mouseUpHandler); } if (canvas._mouseLeaveHandler) { canvas.removeEventListener('mouseleave', canvas._mouseLeaveHandler); } // Remove all touch event listeners if (canvas._touchStartHandler) { canvas.removeEventListener('touchstart', canvas._touchStartHandler); } if (canvas._touchMoveHandler) { canvas.removeEventListener('touchmove', canvas._touchMoveHandler); } if (canvas._touchEndHandler) { canvas.removeEventListener('touchend', canvas._touchEndHandler); } // SMOOTH: Gentle transition without flickering canvas.style.transition = 'z-index 0.1s ease'; canvas.style.setProperty('pointer-events', 'none', 'important'); canvas.style.setProperty('z-index', '1001', 'important'); // Just above media (1000) for annotations // Mark canvas as disabled for select mode canvas._selectModeDisabled = true; console.log('[SELECT TOOL] Drawing canvas completely disabled'); }); // SMOOTH: Gentle media item transitions without flickering const mediaItems = document.querySelectorAll('.canvas-media-item'); mediaItems.forEach(item => { item.removeAttribute('data-drawing-mode'); item.style.transition = 'z-index 0.1s ease'; item.style.setProperty('pointer-events', 'auto', 'important'); item.style.setProperty('cursor', 'move', 'important'); // ?? ENHANCED LAYERING: Don't override z-index - let layer manager handle it // Only set if not already managed by layer system if (!item.style.zIndex || parseInt(item.style.zIndex) < 100) { item.style.setProperty('z-index', '100', 'important'); } console.log('[SELECT TOOL] Media item made interactive smoothly:', item); }); // Aggressive CSS override let mediaSelectStyle = document.getElementById('media-select-tool-override'); if (!mediaSelectStyle) { mediaSelectStyle = document.createElement('style'); mediaSelectStyle.id = 'media-select-tool-override'; document.head.appendChild(mediaSelectStyle); } mediaSelectStyle.textContent = ` .canvas-media-item, .canvas-media-item * { pointer-events: auto !important; cursor: move !important; transition: z-index 0.1s ease; } .media-drawing-canvas { pointer-events: none !important; z-index: 5000 !important; transition: z-index 0.1s ease; } `; console.log('[SELECT TOOL] Complete media select setup with event removal'); // ESSENTIAL: Monitor to keep select tool working (but smoother) if (window.selectToolMonitor) { clearInterval(window.selectToolMonitor); } window.selectToolMonitor = setInterval(() => { if (document.visibilityState && document.visibilityState !== 'visible') return; if (window.currentTool === 'select') { const canvases = document.querySelectorAll('.media-drawing-canvas'); canvases.forEach(canvas => { if (canvas._mouseDownHandler) { canvas.removeEventListener('mousedown', canvas._mouseDownHandler); canvas.removeEventListener('mousemove', canvas._mouseMoveHandler); canvas.removeEventListener('mouseup', canvas._mouseUpHandler); canvas.removeEventListener('mouseleave', canvas._mouseLeaveHandler); canvas.removeEventListener('touchstart', canvas._touchStartHandler); canvas.removeEventListener('touchmove', canvas._touchMoveHandler); canvas.removeEventListener('touchend', canvas._touchEndHandler); canvas._selectModeDisabled = true; canvas.style.setProperty('pointer-events', 'none', 'important'); // ?? LAYER FIX: Use layer manager instead of hardcoded z-index if (window.mediaLayerManager) { window.mediaLayerManager.ensureAnnotationsOnTop(); } else { canvas.style.setProperty('z-index', '5000', 'important'); } } }); const mediaItems = document.querySelectorAll('.canvas-media-item'); mediaItems.forEach(item => { if (item.style.cursor !== 'move') { item.style.setProperty('cursor', 'move', 'important'); item.style.setProperty('pointer-events', 'auto', 'important'); // ?? LAYER FIX: Don't override z-index - let layer manager handle it // Only set if not already managed by layer system if (!item.style.zIndex || parseInt(item.style.zIndex) < 100) { item.style.setProperty('z-index', '100', 'important'); } } }); } else { // Stop monitoring when not in select mode clearInterval(window.selectToolMonitor); window.selectToolMonitor = null; } }, 200); // Less frequent monitoring for smoothness console.log('[SELECT TOOL] Essential monitoring restored for functionality'); } // SEAMLESS: Coordinated tool switching for select - SINGLE CALL ONLY handleSeamlessToolSwitch('select'); // CRITICAL: IMMEDIATE canvas pointer events update updateCanvasPointerEvents(); // Ensure pen size slider still works in select mode if (penSizeSliderPopup) { penSizeSliderPopup.style.pointerEvents = 'auto'; } console.log('[SELECT TOOL] IMMEDIATE activation complete - ready to use'); } hideEraserVisualOnToolChange(); } // Cross-platform pen button event handling penBtn.addEventListener('click', (e) => handleToolSelection('pen', e)); // Touch events for pen button to ensure proper selection on touch devices penBtn.addEventListener('touchend', (e) => { // Only handle if not doing pen size adjustment if (!penSizeHoldActive && !justFinishedSizeAdjustment) { handleToolSelection('pen', e); } else { // If we just finished size adjustment, ensure tool colors are properly restored setTimeout(() => { updateToolUI(); }, 50); } }); // Cross-platform highlight button event handling highlightBtn.addEventListener('click', (e) => handleToolSelection('highlight', e)); // Touch events for highlight button to ensure proper selection on touch devices highlightBtn.addEventListener('touchend', (e) => { handleToolSelection('highlight', e); }); // Cross-platform erase button event handling eraseBtn.addEventListener('click', (e) => handleToolSelection('erase', e)); // Touch events for erase button to ensure proper selection on touch devices eraseBtn.addEventListener('touchend', (e) => { handleToolSelection('erase', e); }); // Cross-platform select button event handling (if available) if (selectBtn) { selectBtn.addEventListener('click', (e) => { handleToolSelection('select', e); }); // Touch events for select button to ensure proper selection on touch devices selectBtn.addEventListener('touchend', (e) => { handleToolSelection('select', e); }); } // Pen color selection penColorDropdown.querySelectorAll('.pen-color-option').forEach(btn => { btn.addEventListener('click', (e) => { const newColor = btn.getAttribute('data-color'); // Don't track color changes - UI state changes shouldn't be undoable penColor = newColor; window.penColor = penColor; // Sync with global variable penBtn.style.setProperty('--pen-color', penColor); sendCompanionPenColorStateFeedback(penColor); updateToolUI(); closeDropdowns(); }); }); // Highlighter color selection highlighterColorDropdown.querySelectorAll('.highlighter-color-option').forEach(btn => { btn.addEventListener('click', (e) => { const newColor = btn.getAttribute('data-color'); // Don't track color changes - UI state changes shouldn't be undoable highlighterColor = newColor; window.highlighterColor = highlighterColor; // Sync with global variable highlightBtn.style.setProperty('--highlighter-color', highlighterColor); updateToolUI(); closeDropdowns(); }); }); // Cross-platform pen size functionality with intuitive hold-and-drag interaction let penSizeSliderInitialized = false; let penSizeHoldTimeout = null; let penSliderActive = false; let penSizeHoldActive = false; let penDragActive = false; let penStartPosition = { x: 0, y: 0 }; let penStartSize = penSize; let lastRecordedPenSize = penSize; let justFinishedSizeAdjustment = false; let sliderWasActuallyShown = false; function showPenSizeSlider() { if (!penSizeSliderPopup) return; penSizeSliderPopup.classList.add('show'); if (penSizeSlider) penSizeSlider.value = penSizeToNotchIndex(penSize); requestAnimationFrame(renderPenSizeScaleBackground); sliderWasActuallyShown = true; console.log('[DEBUG] Pen size slider shown'); } function hidePenSizeSlider() { if (!penSizeSliderPopup) return; penSizeSliderPopup.classList.remove('show'); sliderWasActuallyShown = false; console.log('[DEBUG] Pen size slider hidden'); } function updatePenSizeFromDrag(currentX, currentY) { // Calculate horizontal movement (main adjustment axis); allow a bit of vertical influence. const deltaX = currentX - penStartPosition.x; const deltaY = currentY - penStartPosition.y; const totalMovement = deltaX + (deltaY * 0.3); // Step by NOTCH INDEX (not raw size) so each notch feels equally distant // regardless of which size range we're in. ~24 pixels of drag = one notch. const indexChange = Math.round(totalMovement / 24); const startIndex = penSizeToNotchIndex(penStartSize); const lastIndex = PEN_SIZE_NOTCHES.length - 1; let newIndex = startIndex + indexChange; if (newIndex < 0) newIndex = 0; if (newIndex > lastIndex) newIndex = lastIndex; const newSize = PEN_SIZE_NOTCHES[newIndex]; if (newSize !== penSize) { setPenSize(newSize); console.log('[DEBUG] Pen size updated via drag:', newSize, '(notch', newIndex, ')'); } } function initializePenSizeSlider() { if (penSizeSliderInitialized) { console.log('[DEBUG] Pen size slider already initialized, skipping duplicate initialization'); return; } if (!penBtn || !penSizeSliderPopup || !penSizeSlider) { console.log('[DEBUG] Pen size elements not found, delaying initialization'); setTimeout(initializePenSizeSlider, 100); return; } console.log('[DEBUG] Initializing intuitive hold-and-drag pen size slider'); // Mark as initialized to prevent duplicates penSizeSliderInitialized = true; // Enhanced pen button styling for touch optimization penBtn.style.touchAction = 'manipulation'; penBtn.style.userSelect = 'none'; penBtn.style.webkitUserSelect = 'none'; penBtn.style.mozUserSelect = 'none'; penBtn.style.msUserSelect = 'none'; // Pen size slider styling for touch optimization penSizeSliderPopup.style.touchAction = 'manipulation'; penSizeSliderPopup.style.userSelect = 'none'; penSizeSlider.style.touchAction = 'manipulation'; penSizeSlider.style.userSelect = 'none'; // Sync slider position to the current pen size (defensive: in case some // earlier code or saved state had penSize that doesn't match the HTML default). penSizeSlider.value = penSizeToNotchIndex(penSize); renderPenSizeScaleBackground(); window.addEventListener('resize', () => requestAnimationFrame(renderPenSizeScaleBackground)); // Core pen size hold and drag logic function startPenSizeHold(eventType, clientX, clientY) { if (penSizeHoldActive) { console.log('[DEBUG] Pen size hold already active, preventing duplicate'); return; } console.log('[DEBUG] Starting pen size hold from:', eventType); penSizeHoldActive = true; window.penSizeAdjustActive = true; penStartPosition = { x: clientX, y: clientY }; penStartSize = penSize; lastRecordedPenSize = penSize; clearTimeout(penSizeHoldTimeout); penSizeHoldTimeout = setTimeout(() => { // Only show slider if user is still holding (hasn't released) if (penSizeHoldActive) { console.log('[DEBUG] ?? Pen size hold timeout reached - showing slider'); // Close other dropdowns if (penColorDropdown) penColorDropdown.classList.remove('show'); if (highlighterColorDropdown) highlighterColorDropdown.classList.remove('show'); showPenSizeSlider(); penDragActive = true; // Enable drag adjustment after slider appears } else { console.log('[DEBUG] ?? Pen size hold timeout reached but user already released - not showing slider'); } }, 300); // 300ms hold time for good UX } function handlePenSizeMove(eventType, clientX, clientY) { if (penDragActive && penSizeSliderPopup.classList.contains('show')) { // User is dragging after the slider appeared - adjust size based on movement updatePenSizeFromDrag(clientX, clientY); } } function endPenSizeHold(eventType) { console.log('[DEBUG] ?? Ending pen size hold from:', eventType); clearTimeout(penSizeHoldTimeout); // Don't record size changes - UI state changes shouldn't be undoable console.log('[DEBUG] ?? Pen size changed:', lastRecordedPenSize, '->', penSize); // Block color picker if we actually used the size slider if (sliderWasActuallyShown || penSize !== lastRecordedPenSize) { justFinishedSizeAdjustment = true; console.log('[DEBUG] ?? Blocking color picker - size adjustment was used'); // Clear the flag after a brief delay to allow normal clicks later setTimeout(() => { justFinishedSizeAdjustment = false; console.log('[DEBUG] ?? Color picker blocking cleared'); }, 200); } // Reset drag state and flags penDragActive = false; penSizeHoldActive = false; window.penSizeAdjustActive = false; sliderWasActuallyShown = false; // Reset for next interaction // Hide slider after brief delay setTimeout(() => { if (!penSliderActive) { hidePenSizeSlider(); } }, 500); } // Touch Events (tablets and mobile devices) - Full touch support with hold detection console.log('[DEBUG] ?? Touch events: Full touch support with hold detection'); let touchHoldTimer = null; let touchStartTime = 0; let touchHoldActive = false; penBtn.addEventListener('touchstart', (e) => { console.log('[DEBUG] ?? Touch start on pen button - starting hold detection'); touchStartTime = Date.now(); touchHoldActive = false; // Mark that a touch occurred so mousedown doesn't trigger duplicate logic penBtn._touchOccurred = true; // Start hold timer for touch devices touchHoldTimer = setTimeout(() => { if (!touchHoldActive) { touchHoldActive = true; console.log('[DEBUG] ?? Touch hold detected - starting size adjustment'); startPenSizeHold('touch', e.changedTouches[0].clientX, e.changedTouches[0].clientY); } }, 300); // Clear the mouse event prevention flag after a short delay setTimeout(() => { penBtn._touchOccurred = false; }, 100); }); penBtn.addEventListener('touchend', (e) => { const touchDuration = Date.now() - touchStartTime; console.log('[DEBUG] ?? Touch end on pen button, duration:', touchDuration + 'ms'); // Clear the hold timer if (touchHoldTimer) { clearTimeout(touchHoldTimer); touchHoldTimer = null; } if (touchHoldActive) { // This was a hold - end the size adjustment console.log('[DEBUG] ?? Ending touch hold size adjustment'); endPenSizeHold('touch'); touchHoldActive = false; } else if (touchDuration < 300) { // This was a quick tap - let the normal click handler deal with it console.log('[DEBUG] ?? Quick touch tap detected - allowing normal click behavior'); } }); penBtn.addEventListener('touchcancel', (e) => { console.log('[DEBUG] ?? Touch cancelled on pen button'); if (touchHoldTimer) { clearTimeout(touchHoldTimer); touchHoldTimer = null; } if (touchHoldActive) { endPenSizeHold('touch'); touchHoldActive = false; } }); // Touch move for drag adjustment during hold penBtn.addEventListener('touchmove', (e) => { if (touchHoldActive) { console.log('[DEBUG] ?? Touch move during hold - adjusting size'); handlePenSizeMove('touch', e.changedTouches[0].clientX, e.changedTouches[0].clientY); } }); // Mouse Events (fallback for older browsers and desktop) penBtn.addEventListener('mousedown', (e) => { // Skip if this is a touch-triggered mousedown event if (penBtn._touchOccurred) { console.log('[DEBUG] ?? Mouse down skipped - triggered by touch event'); return; } // Only handle if no touch/pointer events occurred setTimeout(() => { if (!penSizeHoldActive) { console.log('[DEBUG] ?? Mouse down on pen button (fallback)'); startPenSizeHold('mouse', e.clientX, e.clientY); } }, 10); }); penBtn.addEventListener('mouseup', (e) => { // Skip if this is a touch-triggered mouseup event if (penBtn._touchOccurred) { console.log('[DEBUG] ?? Mouse up skipped - triggered by touch event'); return; } if (penSizeHoldActive) { console.log('[DEBUG] ?? Mouse up on pen button'); endPenSizeHold('mouse'); } }); // Global movement tracking for drag adjustment - both mouse and touch document.addEventListener('mousemove', (e) => { if (penDragActive) { handlePenSizeMove('mouse', e.clientX, e.clientY); } }); // Global touch movement tracking for drag adjustment during hold document.addEventListener('touchmove', (e) => { if (penDragActive && touchHoldActive) { handlePenSizeMove('touch', e.touches[0].clientX, e.touches[0].clientY); } }); // Global end tracking for both mouse and touch document.addEventListener('mouseup', (e) => { if (penDragActive) { endPenSizeHold('document-mouse'); } }); document.addEventListener('touchend', (e) => { if (penDragActive && touchHoldActive) { endPenSizeHold('document-touch'); touchHoldActive = false; } }); // Traditional slider interaction (for users who prefer to use the slider handle directly). // The slider value is a notch INDEX (0..N-1), not a raw size, so every notch is evenly // spaced on the track and equally easy to hit on a touchscreen. penSizeSlider.addEventListener('input', (e) => { const idx = parseInt(e.target.value, 10); if (!Number.isFinite(idx) || idx < 0 || idx >= PEN_SIZE_NOTCHES.length) return; const newSize = PEN_SIZE_NOTCHES[idx]; if (newSize !== penSize) { console.log('[DEBUG] Pen size changed via slider:', penSize, '->', newSize, '(notch', idx, ')'); setPenSize(newSize); } }); // Slider interaction state management (for traditional slider use) penSizeSlider.addEventListener('pointerdown', (e) => { penSliderActive = true; lastRecordedPenSize = penSize; console.log('[DEBUG] ?? Direct slider interaction started'); }); penSizeSlider.addEventListener('touchstart', (e) => { penSliderActive = true; lastRecordedPenSize = penSize; console.log('[DEBUG] ?? Direct slider touch started'); }); penSizeSlider.addEventListener('mousedown', (e) => { penSliderActive = true; lastRecordedPenSize = penSize; console.log('[DEBUG] ?? Direct slider mouse down'); }); // End direct slider interaction function endDirectSliderInteraction() { // Only proceed if the user was actually interacting with the slider if (!penSliderActive) return; setTimeout(() => { penSliderActive = false; console.log('[DEBUG] ?? Direct slider interaction ended'); // Don't record size changes - UI state changes shouldn't be undoable if (penSize !== lastRecordedPenSize) { lastRecordedPenSize = penSize; // Block color picker from opening after direct slider use (this is appropriate since slider was definitely used) justFinishedSizeAdjustment = true; console.log('[DEBUG] ?? Blocking color picker - direct slider was used'); // Clear the flag after a brief delay setTimeout(() => { justFinishedSizeAdjustment = false; console.log('[DEBUG] ?? Color picker blocking cleared (direct slider)'); }, 200); } // Hide slider after brief delay setTimeout(() => { if (!penSizeHoldActive && !penDragActive) { hidePenSizeSlider(); } }, 800); }, 50); } // Only listen to slider-specific events, not global document events penSizeSlider.addEventListener('pointerup', endDirectSliderInteraction); penSizeSlider.addEventListener('touchend', endDirectSliderInteraction); penSizeSlider.addEventListener('mouseup', endDirectSliderInteraction); // Also listen for events on the slider popup container penSizeSliderPopup.addEventListener('pointerup', endDirectSliderInteraction); penSizeSliderPopup.addEventListener('touchend', endDirectSliderInteraction); penSizeSliderPopup.addEventListener('mouseup', endDirectSliderInteraction); console.log('[DEBUG] ? Intuitive hold-and-drag pen size slider initialized successfully'); } // Initialize pen size slider initializePenSizeSlider(); // Hide dropdowns if clicking outside window.addEventListener('mousedown', (e) => { if (!penColorDropdown.contains(e.target) && !penBtn.contains(e.target)) penColorDropdown.classList.remove('show'); if (!highlighterColorDropdown.contains(e.target) && !highlightBtn.contains(e.target)) highlighterColorDropdown.classList.remove('show'); // Don't hide size slider if user is actively dragging if (!penSizeSliderPopup.contains(e.target) && !penBtn.contains(e.target) && !penSliderActive) hidePenSizeSlider(); }); updateToolUI(); // Initial call for touch devices to ensure proper styling if (isTouchDevice) { setTimeout(() => { updateToolUI(); console.log('Initial tool UI setup complete for touch device'); }, 200); } } // Touch device detection and optimization let isTouchDevice = false; function isTabScrollDebugEnabled() { try { if (window.__TAB_SCROLL_DEBUG === true) return true; return localStorage.getItem('tab-scroll-debug') === '1'; } catch (_) { return !!window.__TAB_SCROLL_DEBUG; } } function tabScrollDebug(...parts) { if (!isTabScrollDebugEnabled()) return; const msg = parts.map((p) => (typeof p === 'string' ? p : JSON.stringify(p))).join(' '); console.log('[TAB-2F-DEBUG]', msg); try { let badge = document.getElementById('tabScrollDebugBadge'); if (!badge) { badge = document.createElement('div'); badge.id = 'tabScrollDebugBadge'; badge.style.position = 'fixed'; badge.style.right = '8px'; badge.style.top = '8px'; badge.style.zIndex = '120000'; badge.style.maxWidth = '48vw'; badge.style.fontSize = '12px'; badge.style.padding = '6px 8px'; badge.style.borderRadius = '6px'; badge.style.background = 'rgba(12,14,18,0.84)'; badge.style.border = '1px solid rgba(74,158,255,0.45)'; badge.style.color = '#9ec7ff'; badge.style.pointerEvents = 'none'; badge.style.whiteSpace = 'nowrap'; badge.style.overflow = 'hidden'; badge.style.textOverflow = 'ellipsis'; document.body.appendChild(badge); } badge.textContent = msg; } catch (_) {} } // Detect touch capability function detectTouchDevice() { isTouchDevice = (('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0)); if (isTouchDevice) { // Add touch device class to body for CSS targeting document.body.classList.add('touch-device'); // Prevent hover states from persisting after touch document.addEventListener('touchstart', function() {}, {passive: true}); console.log('Touch device detected - optimizations applied'); } else { console.log('Mouse/trackpad device detected'); } } // Initialize touch detection early detectTouchDevice(); // Listen for tab visibility updates from settings window window.addEventListener('message', (event) => { try { if (event.data && event.data.type === 'tab-visibility-updated') { // Re-render tabs when visibility settings change if (window.bibleApp && typeof window.bibleApp.renderTabs === 'function') { window.bibleApp.renderTabs(); } } else if (event.data && event.data.type === 'translation-visibility-updated') { // Refresh the visible translation buttons without disturbing loaded passage content. if (window.bibleApp && window.bibleApp.activeTabId && typeof window.bibleApp.populateTranslations === 'function') { const activeTab = window.bibleApp.tabs?.find(t => t.id === window.bibleApp.activeTabId); if (activeTab) window.bibleApp.populateTranslations(activeTab); } } } catch (_) {} }); // Initialize tool UI when DOM is ready document.addEventListener('DOMContentLoaded', () => { try { if (isTabScrollDebugEnabled() && !window.__TAB_SCROLL_DEBUG_WATCHERS_INSTALLED) { tabScrollDebug('ready', `touchCapable=${String((navigator.maxTouchPoints || 0) > 0)}`); const mkTargetLabel = (t) => { try { if (!t) return 'null'; const inBar = !!(t.closest && t.closest('.tab-bar')); const cls = t.className ? String(t.className).split(' ').slice(0, 2).join('.') : ''; return `${t.tagName || 'node'}${cls ? '.' + cls : ''}${inBar ? ':in-tabbar' : ''}`; } catch (_) { return 'unknown'; } }; window.addEventListener('touchstart', (e) => { tabScrollDebug('win-touchstart', `n=${e.touches ? e.touches.length : 0}`, mkTargetLabel(e.target)); }, { capture: true, passive: true }); window.addEventListener('pointerdown', (e) => { tabScrollDebug('win-pointerdown', `type=${e.pointerType}`, mkTargetLabel(e.target)); }, { capture: true, passive: true }); window.addEventListener('wheel', (e) => { tabScrollDebug('win-wheel', `dx=${Math.round(e.deltaX || 0)}`, `dy=${Math.round(e.deltaY || 0)}`, mkTargetLabel(e.target)); }, { capture: true, passive: true }); window.__TAB_SCROLL_DEBUG_WATCHERS_INSTALLED = true; } if (!isTabScrollDebugEnabled()) { const badge = document.getElementById('tabScrollDebugBadge'); if (badge && badge.parentNode) badge.parentNode.removeChild(badge); } } catch (_) {} // Always add click handler regardless of device type const newTabBtn = document.getElementById('newTabBtn'); const resetAppBtn = document.getElementById('resetAppBtn'); if (newTabBtn) { const handleNewTab = (e = null) => { if (e) { e.preventDefault(); e.stopPropagation(); } console.log('New Tab button activated'); window.bibleApp.createNewTab(); }; const tabTypeOptions = [ { mode: 'bible', label: 'Bible', icon: '+' }, { mode: 'announcements', label: 'Announcements', icon: 'A' }, { mode: 'graphics', label: 'Graphics', icon: 'G' }, { mode: 'maps', label: 'Maps', icon: 'M' }, { mode: 'whiteboard', label: 'Whiteboard', icon: 'WB' }, { mode: 'notes', label: 'Notes', icon: 'N' }, { mode: 'prayer', label: 'Prayer', icon: 'P' }, { mode: 'ask', label: 'Ask (AI)', icon: '\u{1F3A4}' } ]; let quickMenu = null; let quickMenuHideTimer = null; let suppressClickUntil = 0; const setSuppressClick = (ms = 450) => { suppressClickUntil = Date.now() + ms; }; const isSuppressedClick = () => Date.now() < suppressClickUntil; const getTabVisibility = () => { try { if (window.bibleApp && typeof window.bibleApp.getTabVisibilitySettings === 'function') { return window.bibleApp.getTabVisibilitySettings(); } } catch (_) {} return { bible: true, announcements: true, graphics: true, maps: true, whiteboard: true, notes: true, prayer: true, alpha: true, ask: true }; }; const clearQuickMenuHideTimer = () => { if (quickMenuHideTimer) { clearTimeout(quickMenuHideTimer); quickMenuHideTimer = null; } }; const hideQuickTabMenu = () => { clearQuickMenuHideTimer(); if (!quickMenu) return; quickMenu.classList.remove('show'); newTabBtn.classList.remove('menu-open'); }; const scheduleQuickMenuHide = (delay = 2200) => { clearQuickMenuHideTimer(); quickMenuHideTimer = setTimeout(() => { hideQuickTabMenu(); }, delay); }; const createTabByMode = (mode) => { const app = window.bibleApp; if (!app) return; if (mode === 'bible') { app.createNewTab(); return; } const before = new Set((app.tabs || []).map(t => String(t.id))); if (mode === 'announcements' && typeof app.createAnnouncementsTab === 'function') app.createAnnouncementsTab(); else if (mode === 'graphics' && typeof app.createGraphicsTab === 'function') app.createGraphicsTab(); else if (mode === 'maps' && typeof app.createMapsTab === 'function') app.createMapsTab(); else if (mode === 'whiteboard' && typeof app.createWhiteboardTab === 'function') app.createWhiteboardTab(); else if (mode === 'notes' && typeof app.createNotesTab === 'function') app.createNotesTab(); else if (mode === 'prayer' && typeof app.createPrayerTab === 'function') app.createPrayerTab(); else if (mode === 'ask' && typeof app.createAskTab === 'function') app.createAskTab(); const created = (app.tabs || []).find(t => !before.has(String(t.id)) && t.mode === mode); if (created && typeof app.switchTab === 'function') { app.switchTab(created.id); } }; const ensureQuickMenu = () => { if (quickMenu) return quickMenu; const parent = newTabBtn.parentElement; if (!parent) return null; quickMenu = document.createElement('div'); quickMenu.className = 'new-tab-quick-menu'; quickMenu.id = 'newTabQuickMenu'; quickMenu.setAttribute('role', 'menu'); quickMenu.innerHTML = tabTypeOptions.map(opt => ` `).join(''); quickMenu.addEventListener('pointerdown', (ev) => { ev.stopPropagation(); scheduleQuickMenuHide(3200); }); quickMenu.addEventListener('touchstart', (ev) => { ev.stopPropagation(); scheduleQuickMenuHide(3200); }, { passive: true }); quickMenu.addEventListener('click', (ev) => { const btn = ev.target && ev.target.closest ? ev.target.closest('.new-tab-menu-item') : null; if (!btn) return; const mode = btn.getAttribute('data-mode'); if (!mode) return; ev.preventDefault(); ev.stopPropagation(); setSuppressClick(500); hideQuickTabMenu(); createTabByMode(mode); }); parent.appendChild(quickMenu); return quickMenu; }; const openQuickTabMenu = () => { const menu = ensureQuickMenu(); if (!menu) return; const visibility = getTabVisibility(); const items = menu.querySelectorAll('.new-tab-menu-item'); items.forEach((item) => { const mode = item.getAttribute('data-mode'); const visible = mode === 'bible' || visibility[mode] !== false; item.style.display = visible ? '' : 'none'; }); menu.classList.add('show'); newTabBtn.classList.add('menu-open'); scheduleQuickMenuHide(2400); }; const dismissMenuOnOutsideAction = (ev) => { if (!quickMenu || !quickMenu.classList.contains('show')) return; const target = ev.target; if (target === newTabBtn) return; if (target && target.closest && (target.closest('#newTabQuickMenu') || target.closest('#newTabBtn'))) return; hideQuickTabMenu(); }; document.addEventListener('pointerdown', dismissMenuOnOutsideAction, true); document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') hideQuickTabMenu(); }); // Add click handler for all devices newTabBtn.addEventListener('click', (e) => { if (isSuppressedClick()) { e.preventDefault(); e.stopPropagation(); return; } handleNewTab(e); }); const SWIPE_RIGHT_THRESHOLD = 26; const SWIPE_DOWN_THRESHOLD = 24; const SWIPE_CROSS_AXIS_TOLERANCE = 26; const isMenuSwipe = (dx, dy) => ( (dx >= SWIPE_RIGHT_THRESHOLD && Math.abs(dy) <= SWIPE_CROSS_AXIS_TOLERANCE) || (dy >= SWIPE_DOWN_THRESHOLD && Math.abs(dx) <= SWIPE_CROSS_AXIS_TOLERANCE) ); // Add touch-specific handlers only for touch devices if (isTouchDevice) { let startX = 0; let startY = 0; let gestureHandled = false; let touchActive = false; const resetNewTabStyles = () => { newTabBtn.style.background = '#232323'; newTabBtn.style.backgroundColor = '#232323'; newTabBtn.style.setProperty('color', '#4a9eff', 'important'); newTabBtn.style.border = ''; newTabBtn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)'; newTabBtn.style.transform = ''; newTabBtn.style.opacity = ''; newTabBtn.style.filter = ''; }; newTabBtn.addEventListener('touchstart', (e) => { resetNewTabStyles(); const t = e.touches && e.touches[0]; if (!t) return; startX = t.clientX; startY = t.clientY; gestureHandled = false; touchActive = true; }, { passive: true }); newTabBtn.addEventListener('touchmove', (e) => { resetNewTabStyles(); if (!touchActive || gestureHandled) return; const t = e.touches && e.touches[0]; if (!t) return; const dx = t.clientX - startX; const dy = t.clientY - startY; if (isMenuSwipe(dx, dy)) { gestureHandled = true; setSuppressClick(); e.preventDefault(); e.stopPropagation(); openQuickTabMenu(); } }, { passive: false }); newTabBtn.addEventListener('touchend', (e) => { touchActive = false; e.preventDefault(); e.stopPropagation(); resetNewTabStyles(); setTimeout(resetNewTabStyles, 10); if (gestureHandled) return; handleNewTab(e); }); newTabBtn.addEventListener('touchcancel', () => { touchActive = false; gestureHandled = false; resetNewTabStyles(); }, { passive: true }); } // Mouse drag gesture: swipe right/down on + opens quick menu. let mouseStartX = 0; let mouseStartY = 0; let mouseDown = false; let mouseGestureHandled = false; newTabBtn.addEventListener('mousedown', (e) => { if (e.button !== 0) return; mouseDown = true; mouseGestureHandled = false; mouseStartX = e.clientX; mouseStartY = e.clientY; }); newTabBtn.addEventListener('mousemove', (e) => { if (!mouseDown || mouseGestureHandled || (e.buttons & 1) !== 1) return; const dx = e.clientX - mouseStartX; const dy = e.clientY - mouseStartY; if (!isMenuSwipe(dx, dy)) return; mouseGestureHandled = true; setSuppressClick(); e.preventDefault(); e.stopPropagation(); openQuickTabMenu(); }); newTabBtn.addEventListener('mouseup', () => { mouseDown = false; }); newTabBtn.addEventListener('mouseleave', () => { if (!mouseDown) return; mouseDown = false; }); } // Reset app button: refresh the app (same as Ctrl+R) if (resetAppBtn) { const handleResetApp = (e) => { console.log('Reset App button activated (soft reset without clearing annotations)'); e.preventDefault(); e.stopPropagation(); pulseCompanionActionFeedback('refresh'); try { const now = Date.now(); if (window._suppressResetAppUntil && now < window._suppressResetAppUntil) { console.log('[RESET] Suppressed reset immediately after tab close'); return; } } catch (_) {} try { try { window._resetAppRequested = true; } catch (_) {} // Show splash screen first if (typeof window.showSplash === 'function') { window.showSplash('refresh button'); } // Then perform the soft reset if (window.bibleApp && typeof window.bibleApp.resetToStartup === 'function') { window.bibleApp.resetToStartup(true); } } catch (err) { console.error('Error performing soft reset:', err); } }; /* * Deliberate-tap guard. The refresh button sits at the top-left of a 75" touch screen: a palm or a * finger brushing across it counted as a click, which showed the splash AND soft-reset the app * mid-service. A refresh now needs a finger that lands on the button and lifts within 15 px and * 1.2 s, alone (no other finger down, no stroke in progress). */ let __resetDown = null; const __resetPoint = (e) => (e && e.changedTouches && e.changedTouches[0]) || (e && e.touches && e.touches[0]) || e; resetAppBtn.addEventListener('pointerdown', (e) => { __resetDown = { x: e.clientX, y: e.clientY, t: Date.now(), id: e.pointerId }; }, { capture: true }); const __deliberateReset = (e) => { try { if (typeof globalMultiTouchActive !== 'undefined' && globalMultiTouchActive) return false; if (typeof drawing !== 'undefined' && drawing) return false; if (!__resetDown) return !!(e && e.type === 'click' && e.detail > 0 && e.pointerType !== 'touch'); // mouse/keyboard click with no pointerdown record const p = __resetPoint(e); const moved = Math.hypot((p.clientX || 0) - __resetDown.x, (p.clientY || 0) - __resetDown.y); const held = Date.now() - __resetDown.t; return moved <= 15 && held <= 1200; } catch (_) { return true; } }; const guardedReset = (e) => { if (!__deliberateReset(e)) { console.log('[RESET] ignored — not a deliberate tap'); __resetDown = null; return; } __resetDown = null; handleResetApp(e); }; // Click handler for all devices resetAppBtn.addEventListener('click', guardedReset); // Touch-specific for better responsiveness on TVs/tablets if (isTouchDevice) { const resetResetBtnStyles = () => { resetAppBtn.style.background = ''; resetAppBtn.style.backgroundColor = ''; // Keep icon subdued; do not force accent color resetAppBtn.style.removeProperty('color'); resetAppBtn.style.border = ''; resetAppBtn.style.boxShadow = ''; resetAppBtn.style.transform = ''; resetAppBtn.style.opacity = ''; resetAppBtn.style.filter = ''; }; resetAppBtn.addEventListener('touchstart', resetResetBtnStyles); resetAppBtn.addEventListener('touchmove', resetResetBtnStyles); resetAppBtn.addEventListener('touchend', (e) => { e.preventDefault(); e.stopPropagation(); resetResetBtnStyles(); setTimeout(resetResetBtnStyles, 10); guardedReset(e); }); } } setTimeout(initializeToolUI, 100); setTimeout(initializeClearButton, 100); setTimeout(initializeUndoRedoButtons, 100); }); /* * Remote control action dispatcher. * * Exposed as window.__performControlAction so it has TWO callers, not one: * 1. Electron IPC 'control:perform' — the phone remote and Companion driving the live TV. * 2. The admin console's private app copy (admin.html holds it in a same-origin iframe and calls * this directly). That copy runs in a plain browser with no Electron IPC at all, so without a * callable entry point every action below would have had to be reimplemented for it — and any * reimplementation silently loses the persistence and side-effect rules these cases encode. * * Keep it a pure function of (action, payload): both callers must get identical behaviour. */ try { if (typeof window !== 'undefined') { window.__performControlAction = function (actionName, payloadObj) { const message = { action: actionName, payload: payloadObj || {} }; try { const action = message && message.action; const payload = (message && message.payload) || {}; // Remote actions are user activity: keep the 60s idle splash away while the operator is // driving from the phone, exactly as touching the glass would. try { if (typeof window.__resetIdleTimer === 'function') window.__resetIdleTimer(); } catch (_) {} /* * The tab id the phone's TV view works on. getActiveTabId() FIRST, and not bibleApp.activeTabId, * because the two differ in TYPE: getActiveTabId returns dataset.tabId (a string) while activeTabId * is a number. startDrawing stores whatever getActiveTabId gave it, and endDrawing gates on * `drawingTabId === tabId` — so handing it the number drew a stroke that painted on the overlay and * then could never be committed or saved. Same value, same type, or the stroke silently vanishes. */ const mirrorTabId = () => { try { const fromDom = (typeof getActiveTabId === 'function') ? getActiveTabId() : null; if (fromDom) return fromDom; const id = window.bibleApp && window.bibleApp.activeTabId; return id ? String(id) : null; } catch (_) { return null; } }; switch (action) { case 'undo': { if (typeof window.handleUndoClick === 'function') window.handleUndoClick(); else if (typeof undoUnifiedOperation === 'function') undoUnifiedOperation(); break; } case 'redo': { if (typeof window.handleRedoClick === 'function') window.handleRedoClick(); else if (typeof redoUnifiedOperation === 'function') redoUnifiedOperation(); break; } case 'clear_current': { try { const tabId = (window.bibleApp && window.bibleApp.activeTabId) || (typeof getActiveTabId === 'function' ? getActiveTabId() : null); if (tabId && typeof clearAllAnnotations === 'function') { clearAllAnnotations(tabId); } } catch (_) {} break; } case 'clear_all': { try { if (typeof clearAllAnnotationsAcrossApp === 'function') clearAllAnnotationsAcrossApp(); } catch (_) {} break; } /* * TV view on the phone remote: the phone mirrors this window and sends back touches. * * Coordinates arrive NORMALISED (0..1 of the window), never pixels — the phone has no idea how * big the stage screen is, and a 2160x3840 TV, a 1728-wide dev window and whatever the operator * plugs in next all have to land on the same word. */ case 'mirror_scroll': { /* * Two-finger drag on the phone. The payload is CUMULATIVE per gesture: `t` is the total * the finger has travelled since it touched down (fraction of the WINDOW height, sign * follows the finger), `seq` orders packets, `gesture` identifies the drag. Absolute * totals instead of deltas because these arrive as independent HTTP posts: a delta that * is lost or overtaken on the Wi-Fi desynced the phone's picture from the real scroll * for the rest of the gesture, where a restated total self-heals on the next packet. * * Scaled by window.innerHeight, not readingArea.clientHeight: the phone's mirror is the * whole window, so this is what makes a finger moving 10% of the picture move the text * 10% of the picture — anything else and the phone's local guess is always wrong by the * header's height and the picture visibly snaps on every frame. */ try { const tabId = mirrorTabId(); const readingArea = tabId ? document.getElementById(`readingArea${tabId}`) : null; if (!readingArea) break; if (payload.t !== undefined) { const t = Number(payload.t); const seq = Number(payload.seq); if (!isFinite(t)) break; const g = window.__mirrorScrollGesture || (window.__mirrorScrollGesture = { id: null, baseTop: 0, seq: -1 }); if (g.id !== payload.gesture) { g.id = payload.gesture; g.baseTop = readingArea.scrollTop; g.seq = -1; } if (isFinite(seq)) { if (seq <= g.seq) break; // stale packet from earlier in this gesture g.seq = seq; } readingArea.scrollTop = g.baseTop - t * window.innerHeight; } else { // Older phone page still sending per-packet deltas. const dy = Number(payload.dy); if (!isFinite(dy) || dy === 0) break; readingArea.scrollTop -= dy * window.innerHeight; } } catch (_) {} break; } case 'mirror_draw': { /* * One finger on the phone. This deliberately drives the REAL stroke path * (startDrawing -> draw -> endDrawing) with synthetic points instead of painting the canvas * itself: that is what makes a phone underline behave like a finger on the TV — current tool * and colour, undo/redo, per-chapter save and restore, all for free. Anything that reimplemented * stroke painting here would quietly miss the persistence rules. */ try { const phase = String(payload.phase || ''); const pts = Array.isArray(payload.points) ? payload.points : []; const tabId = mirrorTabId(); if (!tabId) break; const readingArea = document.getElementById(`readingArea${tabId}`); if (!readingArea) break; const rect = readingArea.getBoundingClientRect(); // Normalised window coords -> client px. A synthetic event only has to carry what // getPointerPosRelative and startDrawing actually read. const synth = (pt) => ({ clientX: pt.x * window.innerWidth, clientY: pt.y * window.innerHeight, pointerType: 'touch', preventDefault: () => {}, stopPropagation: () => {} }); if (phase === 'start') { const first = synth(pts[0] || { x: 0, y: 0 }); // Ignore touches that land outside the text (header, tab bar, the black around a graphic): // the canvas would clamp them to its edge and streak a line down the margin. if (first.clientX < rect.left || first.clientX > rect.right || first.clientY < rect.top || first.clientY > rect.bottom) break; if (typeof startDrawing === 'function') startDrawing(first); for (let i = 1; i < pts.length; i++) { if (typeof draw === 'function') draw(synth(pts[i])); } } else if (phase === 'move') { for (let i = 0; i < pts.length; i++) { if (typeof draw === 'function') draw(synth(pts[i])); } } else if (phase === 'end') { for (let i = 0; i < pts.length; i++) { if (typeof draw === 'function') draw(synth(pts[i])); } if (typeof endDrawing === 'function') endDrawing(tabId); } else if (phase === 'cancel') { // A second finger landed on the phone, so that one-finger drag was the start of a scroll, // not an underline. Raise the same two flags a cancelled touch stroke raises on the TV and // let endDrawing throw the partial line away (see the multiTouchCancel guard there). window._cancelCurrentStroke = true; window._multiTouchDuringCurrentStroke = true; if (typeof endDrawing === 'function') endDrawing(tabId); } } catch (e) { try { console.warn('[REMOTE] mirror_draw failed:', e && e.message); } catch (_) {} } break; } // The phone remote's reading-navigation buttons. These call exactly what the on-screen // chapter-header buttons call, so scrolling from the phone behaves identically. case 'scroll_top': { try { if (window.__headerNav) window.__headerNav.top(window.bibleApp.activeTabId); } catch (_) {} break; } case 'scroll_bottom': { try { if (window.__headerNav) window.__headerNav.bottom(window.bibleApp.activeTabId); } catch (_) {} break; } case 'scroll_up_5': { try { if (window.__headerNav) window.__headerNav.by(-5, window.bibleApp.activeTabId); } catch (_) {} break; } case 'scroll_down_5': { try { if (window.__headerNav) window.__headerNav.by(5, window.bibleApp.activeTabId); } catch (_) {} break; } case 'reset_app': { try { if (window && window.location && typeof window.location.reload === 'function') window.location.reload(); } catch (_) {} break; } case 'close_active_tab': { try { const tabId = window.bibleApp && window.bibleApp.activeTabId; if (tabId && window.bibleApp && typeof window.bibleApp.closeTab === 'function') { window.bibleApp.closeTab(tabId); } } catch (_) {} break; } case 'switch_tab_left': { try { const app = window.bibleApp; const tabs = app && app.tabs; if (!app || !Array.isArray(tabs) || tabs.length <= 1) break; const activeId = app.activeTabId; const idx = tabs.findIndex(t => t.id === activeId); const len = tabs.length; const target = tabs[(idx > 0 ? idx - 1 : len - 1)]; if (target && typeof app.switchTab === 'function') app.switchTab(target.id); } catch (_) {} break; } case 'switch_tab_right': { try { const app = window.bibleApp; const tabs = app && app.tabs; if (!app || !Array.isArray(tabs) || tabs.length <= 1) break; const activeId = app.activeTabId; const idx = tabs.findIndex(t => t.id === activeId); const len = tabs.length; const target = tabs[(idx + 1) % len]; if (target && typeof app.switchTab === 'function') app.switchTab(target.id); } catch (_) {} break; } case 'tool': { const name = (payload && payload.name ? String(payload.name) : '').toLowerCase(); if (!name) break; try { // Prefer clicking the UI buttons (ensures all side effects and history entries) let btn = null; if (name === 'pen' && window.penBtn) btn = window.penBtn; else if (name === 'highlight' && window.highlightBtn) btn = window.highlightBtn; else if (name === 'erase' && window.eraseBtn) btn = window.eraseBtn; else if (name === 'select' && window.selectBtn) btn = window.selectBtn; if (btn && typeof btn.click === 'function') { btn.click(); } else if (typeof handleToolSelection === 'function') { handleToolSelection(name); } else { // Fallback: set tool directly window.currentTool = name; if (typeof updateToolUI === 'function') updateToolUI(); if (typeof updateCanvasPointerEvents === 'function') updateCanvasPointerEvents(); } } catch (_) {} break; } case 'new_bible_tab': { try { if (window.bibleApp && typeof window.bibleApp.createNewTab === 'function') window.bibleApp.createNewTab(); } catch (_) {} break; } case 'new_whiteboard_tab': { try { if (window.bibleApp && typeof window.bibleApp.createWhiteboardTab === 'function') window.bibleApp.createWhiteboardTab(); } catch (_) {} break; } case 'new_notes_tab': { try { if (window.bibleApp && typeof window.bibleApp.createNotesTab === 'function') window.bibleApp.createNotesTab(); } catch (_) {} break; } case 'splash_show': { pulseCompanionActionFeedback('splash_show'); try { if (typeof window.showSplash === 'function') window.showSplash('remote / Companion: splash_show'); } catch (_) {} break; } case 'pen_color': { try { // Respect tab-specific color rules: if we just switched to Bible tab from Whiteboard/Slides, // ignore OSC color changes (tab switching takes priority over OSC commands for bug fixing) if (window._bibleTabAutoYellow) { console.log('[OSC] Ignoring pen_color command - tab switch color takes priority'); break; } const color = payload && payload.color ? String(payload.color).trim() : null; if (color && color.startsWith('#')) { // Set pen color penColor = color; window.penColor = penColor; // Update UI const penBtn = window.penBtn || document.getElementById('penBtn'); if (penBtn) { penBtn.style.setProperty('--pen-color', penColor); penBtn.style.color = penColor; } sendCompanionPenColorStateFeedback(penColor); // Update tool UI if function exists if (typeof updateToolUI === 'function') updateToolUI(); // Close any open dropdowns if (typeof closeDropdowns === 'function') closeDropdowns(); } } catch (e) { try { console.error('[pen_color] handler error:', e); } catch (_) {} } break; } case 'redLetterToggle': { try { if (window.redLetter && typeof window.redLetter.toggle === 'function') window.redLetter.toggle(); } catch (_) {} break; } /* * laser / spotlight from a remote are IDEMPOTENT when the caller states its intent. * presentationTools.laser()/spotlight() toggle, so a duplicated or mis-landed tap on the phone * used to flip the tool on and straight back off — the operator saw the tool changing by * itself. The phone now sends { on: true|false } from what it is already showing, so repeating * the same instruction cannot change anything. Payloads without `on` keep the old toggle so * OSC/Companion buttons behave as before. */ case 'laser': { try { const pt = window.presentationTools; if (!pt) break; if (payload && typeof payload.on === 'boolean') { const isOn = pt.getMode() === 'laser'; if (payload.on && !isOn) pt.laser(); else if (!payload.on && isOn) pt.off(); } else if (typeof pt.laser === 'function') pt.laser(); } catch (_) {} break; } case 'spotlight': { try { const pt = window.presentationTools; if (!pt) break; if (payload && typeof payload.on === 'boolean') { const isOn = pt.getMode() === 'spotlight'; if (payload.on && !isOn) pt.spotlight(); else if (!payload.on && isOn) pt.off(); } else if (typeof pt.spotlight === 'function') pt.spotlight(); } catch (_) {} break; } // Double-tapping a tab on the phone closes that exact tab. case 'close_tab': { try { const app = window.bibleApp; const wanted = payload && payload.id != null ? String(payload.id) : ''; if (app && typeof app.closeTab === 'function') { const t = app.tabs.find(x => String(x.id) === wanted); if (t) app.closeTab(t.id); } } catch (_) {} break; } case 'presentOff': { try { if (window.presentationTools && typeof window.presentationTools.off === 'function') window.presentationTools.off(); } catch (_) {} break; } case 'chapterNext': case 'chapterPrev': { try { const app = window.bibleApp; if (app && typeof app.navigateAdjacentChapter === 'function' && app.activeTabId != null) { app.navigateAdjacentChapter(app.activeTabId, action === 'chapterNext' ? 1 : -1); try { if (typeof window.hideSplash === 'function') window.hideSplash(); } catch (_) {} } } catch (_) {} break; } case 'dockFloat': { try { if (window.quickDock && typeof window.quickDock.floatDock === 'function') window.quickDock.floatDock(); } catch (_) {} break; } case 'dockRail': { try { if (window.quickDock && typeof window.quickDock.dockToRail === 'function') window.quickDock.dockToRail(); } catch (_) {} break; } case 'applyProfile': { try { const n = payload && payload.name; if (n && typeof window.applyServiceProfile === 'function') window.applyServiceProfile(n); } catch (_) {} break; } /* * Set one Settings value and apply it to the running stage. * * payload: { key, value, reload? } * * Settings normally only take hold when the operator walks back into the app, and * applyServiceProfile() gets its effect by reloading the page. Neither is acceptable for * an admin pushing a single setting to a TV that is mid-service, so each key applies * in place here. `reload: true` is the explicit escape hatch for a caller that wants the * profile-style full restart. */ case 'setSetting': { try { const key = String((payload && payload.key) || '').trim(); if (!key) break; const raw = payload.value; const str = (typeof raw === 'string') ? raw : JSON.stringify(raw); localStorage.setItem(key, str); switch (key) { case 'tabVisibility': // Re-render the tab bar; renderTabs() also moves off a tab it just hid. if (window.bibleApp && typeof window.bibleApp.renderTabs === 'function') window.bibleApp.renderTabs(); break; case 'bibleFontSize': { const px = Math.max(40, Math.min(120, parseInt(str, 10) || 60)); document.documentElement.style.setProperty('--bible-font-size', px + 'px'); break; } case 'redLetterEnabled': if (window.redLetter && typeof window.redLetter.setEnabled === 'function') { window.redLetter.setEnabled(str === 'true' || raw === true); } break; case 'redLetterOverrides': if (typeof window.applyRedLetterMarks === 'function') { try { window.applyRedLetterMarks(); } catch (_) {} } break; case 'toolVisibility': if (window.quickDock && typeof window.quickDock.refreshToolVisibility === 'function') { window.quickDock.refreshToolVisibility(); } break; default: /* * translationVisibility, popupVisibility, wordPopupsEnabled and * carouselCrossfadeMs are all read at the moment they are used (opening the * translation picker, building a word card, starting a crossfade), so writing * the value IS applying it — there is nothing to refresh. */ break; } if (payload && payload.reload) setTimeout(() => { try { window.location.reload(); } catch (_) {} }, 150); } catch (e) { try { console.warn('[ADMIN] setSetting failed:', e && e.message); } catch (_) {} } break; } case 'gotoPassage': { try { const ref = payload && payload.ref ? String(payload.ref) : ''; // Tolerant parse: "John 3", "John 3:16", "John 3 16", "John 3.16", "John 3:16-18". let r = null; /* * The shared resolver goes first: it is the only one that handles abbreviations and a * missing space ("Heb 11.4", "1cor13:4", "ps23"), and it refuses to guess between two books * rather than opening the wrong one. The older parsers stay as the fallback. */ try { if (window.BibleBooks && typeof window.BibleBooks.parseReference === 'function') { const p = window.BibleBooks.parseReference(ref); if (p && p.chapter) { r = { book: p.book, chapter: p.chapter, vStart: p.verse || 1, vEnd: p.verseEnd || (p.verse || 1) }; } } } catch (_) {} const strict = (!r && ref && typeof __parseScriptureReferences === 'function') ? __parseScriptureReferences(ref) : []; if (!r && strict.length) r = strict[0]; if (!r) { const m = ref.trim().replace(/\s+/g, ' ').match(/^(.+?)\s+(\d+)(?:[\s:.]+(\d+))?(?:\s*-\s*(\d+))?$/); if (m) { const book = (typeof __normalizeGroundingBook === 'function') ? __normalizeGroundingBook(m[1]) : null; if (book) { r = { book, chapter: parseInt(m[2], 10), vStart: m[3] ? parseInt(m[3], 10) : 1, vEnd: m[4] ? parseInt(m[4], 10) : (m[3] ? parseInt(m[3], 10) : 1) }; } } } if (r) { const app = window.bibleApp; const wantNewTab = !!(payload && payload.newTab); // phone remote: never overwrite the operator's tab let tab = null; if (!wantNewTab) { // OSC behavior: prefer the active Bible tab, else reuse the first existing one. tab = app.tabs.find(t => t.id === app.activeTabId && t.mode === 'bible'); if (!tab) tab = app.tabs.find(t => t.mode === 'bible'); } if (!tab) { app.createNewTab(); tab = app.tabs.filter(t => t.mode === 'bible').slice(-1)[0]; } if (tab) { tab.book = r.book; tab.chapter = r.chapter; tab.verse = r.vStart || 1; tab.verseEnd = (r.vEnd && r.vEnd !== r.vStart) ? r.vEnd : null; tab.loaded = false; // Open AT the requested verse, not the chapter top (re-armed retries: fresh // chapters outlive the scroller's budget and the renderer resets scroll on paint). if (tab.verse > 1) { tab._scrollToVerse = tab.verse; tab._jumpRetries = 0; const t2 = tab; [900, 1800, 3200, 5000].forEach(ms => setTimeout(() => { try { if (t2._scrollToVerse) { t2._jumpRetries = 0; if (typeof app.autoScrollToSelectedVerse === 'function') app.autoScrollToSelectedVerse(t2.id); } } catch (_) {} }, ms)); } if (wantNewTab) { /* * Load the passage BEFORE showing the tab. Switching to a brand-new Bible tab first * put the book picker on screen — a new tab has no book yet — and the passage only * replaced it a moment later, so from the phone it looked like the app was walking * through books and chapters to get there. Loading while the tab is still in the * background means the first thing the room sees is the verse itself. */ const tid = tab.id; const reveal = () => { try { if (typeof app.switchTab === 'function') app.switchTab(tid); } catch (_) {} }; let pending = null; try { pending = (typeof app.loadPassage === 'function') ? app.loadPassage(tid) : null; } catch (_) {} if (pending && typeof pending.then === 'function') pending.then(reveal, reveal); else setTimeout(reveal, 60); } else { if (typeof app.switchTab === 'function') app.switchTab(tab.id); if (typeof app.loadPassage === 'function') app.loadPassage(tab.id); } try { if (typeof window.hideSplash === 'function') window.hideSplash(); } catch (_) {} } } } catch (_) {} break; } case 'showMaps': { try { const app = window.bibleApp; const t = app && app.tabs && app.tabs.find(x => x.mode === 'maps'); if (t && typeof app.switchTab === 'function') app.switchTab(t.id); } catch (_) {} break; } case 'switchToMode': { try { const app = window.bibleApp; const m = payload && payload.mode; if (app && m && app.tabs) { const t = app.tabs.find(x => x.mode === m); if (t && typeof app.switchTab === 'function') { app.switchTab(t.id); try { if (typeof window.hideSplash === 'function') window.hideSplash(); } catch (_) {} } } } catch (_) {} break; } case 'switchTabId': { try { const app = window.bibleApp; const id = payload && payload.id; if (app && id != null && app.tabs && app.tabs.some(x => String(x.id) === String(id))) { const t = app.tabs.find(x => String(x.id) === String(id)); if (typeof app.switchTab === 'function') app.switchTab(t.id); try { if (typeof window.hideSplash === 'function') window.hideSplash(); } catch (_) {} } } catch (_) {} break; } case 'splashToggle': { try { const ov = document.querySelector('.splash-overlay'); const visible = !!(ov && !ov.classList.contains('hidden')); if (visible) { if (typeof window.hideSplash === 'function') window.hideSplash(); } else { if (typeof window.showSplash === 'function') window.showSplash('remote / Companion: splash toggle'); } } catch (_) {} break; } case 'splash_hide': { /* * A tap on the phone's TV view while the splash is up — dismiss it, exactly like touching * the TV glass. Hide-only (never a toggle) and guarded on visibility, so the phone can send * it on any tap without a stale state snapshot ever flashing the splash back ON. */ try { const ov = document.querySelector('.splash-overlay'); if (ov && !ov.classList.contains('hidden') && typeof window.hideSplash === 'function') window.hideSplash(); } catch (_) {} break; } case 'mirror_tap': { /* * A tap (no travel) on the phone's TV view: replay it on the TV as a real tap at the same * spot. Hit-test the top element exactly like a finger would, then run the full * pointer -> mouse -> click sequence so every kind of handler fires — tab bar tabs, the * book/chapter/translation controls in the sticky header, verse and word taps. This is what * makes the mirror behave like the glass instead of just showing it. */ try { const px = Number(payload.x), py = Number(payload.y); if (!isFinite(px) || !isFinite(py)) break; const x = px * window.innerWidth, y = py * window.innerHeight; const ov = document.querySelector('.splash-overlay'); if (ov && !ov.classList.contains('hidden')) { try { window.hideSplash(); } catch (_) {} break; } const el = document.elementFromPoint(x, y); if (!el) break; const opts = { clientX: x, clientY: y, bubbles: true, cancelable: true, view: window }; /* * Replay the tap EXACTLY like a finger on the glass: touchstart -> touchend, then a click * only if touchend was not default-prevented (that is the browser's own rule for real * taps). The TV's grids are a mix — books select on click, chapters on a touch tap, * verses on a preventDefault-ing touchend — and this sequence satisfies all of them * without double-firing anything. Mouse events are deliberately absent: the verse grid * starts a range selection on mousedown, which then swallows the tap. */ try { const touch = new Touch({ identifier: 9901, target: el, clientX: x, clientY: y, pageX: x, pageY: y, screenX: x, screenY: y }); el.dispatchEvent(new TouchEvent('touchstart', { touches: [touch], targetTouches: [touch], changedTouches: [touch], bubbles: true, cancelable: true })); const endNotPrevented = el.dispatchEvent(new TouchEvent('touchend', { touches: [], targetTouches: [], changedTouches: [touch], bubbles: true, cancelable: true })); if (endNotPrevented) el.dispatchEvent(new MouseEvent('click', opts)); } catch (_) { // Touch constructors unavailable: fall back to a plain click. try { el.dispatchEvent(new MouseEvent('click', opts)); } catch (_) {} } } catch (e) { try { console.warn('[REMOTE] mirror_tap failed:', e && e.message); } catch (_) {} } break; } default: break; } return true; } catch (e) { try { console.error('[control:perform] handler error:', e); } catch (_) {} return false; } }; } } catch (_) {} // Live TV path: Electron IPC hands straight to the dispatcher above. try { if (typeof window !== 'undefined' && window.electron && window.electron.ipcRenderer && typeof window.electron.ipcRenderer.on === 'function') { window.electron.ipcRenderer.on('control:perform', (_evt, message) => { try { if (typeof window.__performControlAction === 'function') { window.__performControlAction(message && message.action, (message && message.payload) || {}); } } catch (e) { try { console.error('[control:perform] dispatch error:', e); } catch (_) {} } }); } } catch (_) {} // --- SELECT TOOL & WORD POPUP LOGIC --- // Enable word selection functionality function enableWordSelection() { console.log('[ENABLE WORD SELECTION] Starting - IMMEDIATE mode'); // CRITICAL: Force set state immediately selectModeActive = true; window.selectModeActive = true; currentTool = 'select'; window.currentTool = 'select'; console.log('[ENABLE WORD SELECTION] State set - selectModeActive:', selectModeActive, 'currentTool:', currentTool); // Add visual indicator that select mode is active document.body.classList.add('select-mode-active'); // Style the cursor for better UX const activeTabId = getActiveTabId(); const readingArea = document.getElementById(`readingArea${activeTabId}`); if (readingArea) { readingArea.style.cursor = 'pointer'; } // CRITICAL: Update canvas pointer events IMMEDIATELY updateCanvasPointerEvents(); // Ensure pen size slider still works in select mode if (penSizeSliderPopup) { penSizeSliderPopup.style.pointerEvents = 'auto'; } // Add word click listeners to the current active tab IMMEDIATELY if (activeTabId) { console.log('[ENABLE WORD SELECTION] Setting up word listeners for tab:', activeTabId); // Force clean slate before adding listeners removeWordClickListeners(activeTabId); // Make words clickable and add listeners makeWordsClickableInTab(activeTabId); addWordClickListeners(activeTabId); console.log('[ENABLE WORD SELECTION] Word listeners setup complete for tab:', activeTabId); } console.log('[ENABLE WORD SELECTION] Complete - ready for word selection'); } // Test function to verify select tool works window.testSelectToolNow = function() { console.log('=== SELECT TOOL VERIFICATION ==='); const currentTool = window.currentTool; console.log('Current tool:', currentTool); if (currentTool !== 'select') { console.log('? Select tool not active. Click the select tool first.'); return; } const mediaItems = document.querySelectorAll('.canvas-media-item'); console.log('Media items found:', mediaItems.length); const drawingCanvases = document.querySelectorAll('.media-drawing-canvas'); console.log('Drawing canvases found:', drawingCanvases.length); drawingCanvases.forEach((canvas, index) => { console.log(`Canvas ${index} disabled:`, canvas._selectModeDisabled); console.log(`Canvas ${index} pointer-events:`, canvas.style.pointerEvents); console.log(`Canvas ${index} has mouse handler:`, !!canvas._mouseDownHandler); }); mediaItems.forEach((item, index) => { const computedStyle = window.getComputedStyle(item); console.log(`Media item ${index} cursor:`, computedStyle.cursor); console.log(`Media item ${index} pointer-events:`, computedStyle.pointerEvents); }); if (mediaItems.length > 0 && currentTool === 'select') { console.log('? Select tool should work! Layering: Annotations (3000) > Media (2000)'); console.log(' Annotations stay on top, media items are interactive below.'); console.log(' Try clicking and dragging a media item now.'); } else if (mediaItems.length === 0) { console.log('? No media items found - drag some media to the canvas first'); } else { console.log('? Select tool not active - click the select tool button first'); } console.log('=== VERIFICATION COMPLETE ==='); }; // EMERGENCY: Force select tool to work RIGHT NOW window.forceSelectToolNow = function() { console.log('?? EMERGENCY: Forcing select tool to work immediately!'); // Set tool state window.currentTool = 'select'; window.selectModeActive = true; // Remove ALL event listeners from ALL canvases const allCanvases = document.querySelectorAll('canvas'); allCanvases.forEach(canvas => { const events = ['mousedown', 'mousemove', 'mouseup', 'mouseleave', 'touchstart', 'touchmove', 'touchend']; events.forEach(event => { canvas.removeEventListener(event, canvas[`_${event}Handler`]); }); canvas.style.setProperty('pointer-events', 'none', 'important'); canvas.style.setProperty('z-index', '3000', 'important'); // Annotations on top }); // Force ALL media items to be interactive const allMedia = document.querySelectorAll('.canvas-media-item, [class*="media"], img, video'); allMedia.forEach(item => { item.style.setProperty('cursor', 'move', 'important'); item.style.setProperty('pointer-events', 'auto', 'important'); item.style.setProperty('z-index', '2000', 'important'); }); // Add mega aggressive CSS let forceStyle = document.getElementById('force-select-tool'); if (forceStyle) forceStyle.remove(); forceStyle = document.createElement('style'); forceStyle.id = 'force-select-tool'; forceStyle.textContent = ` .canvas-media-item, .canvas-media-item *, [data-media-path], [data-media-type], img, video { cursor: move !important; pointer-events: auto !important; } canvas, .media-drawing-canvas { pointer-events: none !important; z-index: 5000 !important; } `; document.head.appendChild(forceStyle); console.log('?? EMERGENCY FIX APPLIED! Try moving media items now.'); }; // Make all words in a tab clickable function makeWordsClickableInTab(tabId) { const bibleText = document.getElementById(`bibleText${tabId}`); if (!bibleText) return; console.log('?? MAKING WORDS CLICKABLE - Processing tab:', tabId); // Check if words are already clickable const existingClickableWords = bibleText.querySelectorAll('.clickable-word'); if (existingClickableWords.length > 0) { console.log('?? MAKING WORDS CLICKABLE - Words already clickable:', existingClickableWords.length); return; } // Process all verses to make words clickable const verses = bibleText.querySelectorAll('.verse'); console.log('?? MAKING WORDS CLICKABLE - Found verses:', verses.length); verses.forEach((verse, verseIndex) => { console.log(`?? MAKING WORDS CLICKABLE - Processing verse ${verseIndex}:`, verse.textContent.substring(0, 50) + '...'); // Create a document fragment to build the new content const fragment = document.createDocumentFragment(); // Process each child node Array.from(verse.childNodes).forEach(node => { if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) { // This is a text node - make words clickable const clickableHTML = makeWordsClickable(node.textContent); const tempDiv = document.createElement('div'); tempDiv.innerHTML = clickableHTML; // Move all created nodes to the fragment while (tempDiv.firstChild) { fragment.appendChild(tempDiv.firstChild); } } else { // This is an element node (like verse numbers) - keep as is fragment.appendChild(node.cloneNode(true)); } }); // Clear the verse and append the new content verse.innerHTML = ''; verse.appendChild(fragment); }); // Count the final result const finalClickableWords = bibleText.querySelectorAll('.clickable-word'); console.log('?? MAKING WORDS CLICKABLE - Final clickable words created:', finalClickableWords.length); // Red-letter: mark the words of Christ (visibility is toggled via body.red-letter-on) try { applyRedLetterMarks(tabId); } catch (_) {} } // Mark the words of Christ (red-letter) on already-wrapped word spans. Decoupled + re-runnable so // it applies on every chapter render and again once the dataset loads. It never changes colour // itself — CSS colours .red-letter only when body has .red-letter-on, so toggling is instant. // Data (window.__redLetterData) is verse-level {Book:{chapter:[verses]}}; within a flagged verse we // redden only the words inside quotation marks (tracking “ ” across the chapter so multi-verse // discourses stay red), falling back to the whole verse when a flagged verse has no quote marks. function applyRedLetterMarks(tabId) { const data = window.__redLetterData; const bibleText = document.getElementById(`bibleText${tabId}`); if (!bibleText) return; // Always clear stale marks first (covers translation/chapter changes and data-not-yet-loaded). bibleText.querySelectorAll('.clickable-word.red-letter').forEach(w => w.classList.remove('red-letter')); if (!data) return; const tab = window.bibleApp && window.bibleApp.tabs && window.bibleApp.tabs.find(t => t.id === tabId); if (!tab || tab.mode !== 'bible' || !tab.book || !tab.chapter) return; const chap = data[tab.book] && data[tab.book][String(tab.chapter)]; const wocSet = new Set(Array.isArray(chap) ? chap : []); if (wocSet.size === 0) return; let quoteOpen = false; bibleText.querySelectorAll('.verse').forEach(verse => { const vn = parseInt(verse.getAttribute('data-verse'), 10); if (!wocSet.has(vn)) { quoteOpen = false; return; } // a non-Jesus verse ends any carried quote const vtext = verse.textContent || ''; const wholeVerse = vtext.indexOf('“') === -1 && vtext.indexOf('”') === -1 && !quoteOpen; const tw = document.createTreeWalker(verse, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, null); let node; while ((node = tw.nextNode())) { if (node.nodeType === Node.TEXT_NODE) { const t = node.textContent; for (let i = 0; i < t.length; i++) { if (t[i] === '“') quoteOpen = true; else if (t[i] === '”') quoteOpen = false; } } else if (node.nodeType === Node.ELEMENT_NODE && node.classList && node.classList.contains('clickable-word')) { if (wholeVerse || quoteOpen) node.classList.add('red-letter'); } } }); } window.applyRedLetterMarks = applyRedLetterMarks; // Apply a service profile live (used by the OSC /bibleapp/profile/apply command). The server writes // the profile's default media + OSC config; here we set the browser-side prefs (tab visibility, font // size) then reload so the whole app comes up in that profile's configuration. window.applyServiceProfile = async function (name) { try { const r = await fetch('/api/profiles/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name }) }); const j = await r.json().catch(() => null); if (!j || !j.ok || !j.profile) return false; const p = j.profile; try { if (p.tabs) localStorage.setItem('tabVisibility', JSON.stringify(p.tabs)); } catch (_) {} try { if (p.bibleFontSize) localStorage.setItem('bibleFontSize', String(p.bibleFontSize)); } catch (_) {} // Restore the full settings bundle (tool visibility, crossfade, red-letter, font size, tabs…). try { if (p.settings && typeof p.settings === 'object') Object.keys(p.settings).forEach(function (k) { var v = p.settings[k]; if (v !== null && v !== undefined) localStorage.setItem(k, String(v)); }); } catch (_) {} window.location.reload(); return true; } catch (_) { return false; } }; // Disable word selection functionality // Helper function to update canvas pointer events based on select mode function updateCanvasPointerEvents() { // Update canvas pointer events for all tabs Object.keys(annotationCanvases).forEach(tabId => { const canvas = annotationCanvases[tabId]; if (canvas) { canvas.style.pointerEvents = selectModeActive ? 'none' : 'auto'; } }); // Also update any canvases that might exist but not be in the annotationCanvases object document.querySelectorAll('.annotation-canvas').forEach(canvas => { canvas.style.pointerEvents = selectModeActive ? 'none' : 'auto'; }); // Ensure pen size slider still works in select mode if (penSizeSliderPopup) { penSizeSliderPopup.style.pointerEvents = 'auto'; } // Ensure media canvases always have pointer events enabled document.querySelectorAll('.media-canvas').forEach(canvas => { canvas.style.pointerEvents = 'auto'; }); // Ensure media cards always have pointer events enabled document.querySelectorAll('.media-card').forEach(card => { card.style.pointerEvents = 'auto'; }); // Ensure media items on canvas have appropriate pointer events and cursor based on current tool document.querySelectorAll('.canvas-media-item').forEach(item => { const isDrawingTool = window.currentTool === 'pen' || window.currentTool === 'highlight' || window.currentTool === 'erase'; const isSelectTool = window.currentTool === 'select'; if (isDrawingTool) { item.style.pointerEvents = 'none'; item.style.setProperty('pointer-events', 'none', 'important'); item.style.setProperty('cursor', 'default', 'important'); item.setAttribute('data-drawing-mode', 'true'); } else if (isSelectTool) { // Simple select tool fix item.removeAttribute('data-drawing-mode'); item.style.pointerEvents = 'auto'; item.style.setProperty('pointer-events', 'auto', 'important'); item.style.setProperty('cursor', 'move', 'important'); } else { item.style.pointerEvents = 'auto'; item.style.setProperty('pointer-events', 'auto', 'important'); item.style.setProperty('cursor', 'grab', 'important'); } // ?? ENHANCED LAYERING: Don't override z-index here - let layer manager handle it // The layer manager maintains proper stacking order if (!item.style.zIndex || parseInt(item.style.zIndex) < 100) { // Only set base z-index if not already managed item.style.setProperty('z-index', '100', 'important'); } }); // Handle media drawing canvases - ?? ENHANCED LAYERING: annotations always on top document.querySelectorAll('.media-drawing-canvas').forEach(canvas => { // Canvas (annotations) always stays on top - use proper layer system canvas.style.display = 'block'; canvas.style.setProperty('display', 'block', 'important'); // ?? ENHANCED FIX: Don't override z-index here - let local-media-handler manage it // The local-media-handler.updateCanvasStyling() already handles z-index properly // Only set z-index if it's not already properly set const currentZIndex = parseInt(canvas.style.zIndex) || 0; if (currentZIndex < 5000) { if (window.mediaLayerManager) { window.mediaLayerManager.ensureAnnotationsOnTop(); } else { canvas.style.setProperty('z-index', '5000', 'important'); } } // ?? ENHANCED FIX: Preserve pointer-events management from local-media-handler // Don't override if already properly managed if (!canvas.style.pointerEvents) { if (selectModeActive || window.currentTool === 'select') { canvas.style.pointerEvents = 'none'; canvas.style.setProperty('pointer-events', 'none', 'important'); console.log('[CANVAS FIX] Set drawing canvas to pointer-events: none for select tool'); } else { canvas.style.pointerEvents = 'auto'; canvas.style.setProperty('pointer-events', 'auto', 'important'); } } }); // For media tabs, ensure annotation canvases don't interfere with media items const mediaActiveTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); if (mediaActiveTab && mediaActiveTab.mode === 'media') { // Remove annotation canvases from media tabs completely const readingArea = document.getElementById(`readingArea${mediaActiveTab.id}`); if (readingArea) { const annotationCanvases = readingArea.querySelectorAll('.annotation-canvas, .annotation-overlay-canvas'); annotationCanvases.forEach(canvas => { canvas.remove(); console.log('[DEBUG] Removed annotation canvas from media tab during pointer events update'); }); } // Also disable any remaining annotation canvases globally document.querySelectorAll('.annotation-canvas, .annotation-overlay-canvas').forEach(canvas => { canvas.style.pointerEvents = 'none'; canvas.style.setProperty('pointer-events', 'none', 'important'); canvas.style.setProperty('z-index', '-1', 'important'); }); } // CRITICAL: Ensure the media add button (refreshMediaBtn) is always clickable const mediaAddButton = document.getElementById('refreshMediaBtn'); if (mediaAddButton) { mediaAddButton.style.pointerEvents = 'auto'; mediaAddButton.style.cursor = 'pointer'; mediaAddButton.style.zIndex = '10000'; // Also ensure all child elements are clickable const addIcon = mediaAddButton.querySelector('.add-icon'); if (addIcon) { addIcon.style.pointerEvents = 'auto'; addIcon.style.cursor = 'pointer'; } // Ensure the file input is also clickable const fileInput = mediaAddButton.querySelector('input[type="file"]'); if (fileInput) { fileInput.style.pointerEvents = 'auto'; } } // Add a global function to force media button protection window.forceMediaButtonProtection = () => { const mediaAddButton = document.getElementById('refreshMediaBtn'); if (mediaAddButton) { mediaAddButton.style.pointerEvents = 'auto'; mediaAddButton.style.cursor = 'pointer'; mediaAddButton.style.zIndex = '10000'; // Force all child elements to be clickable const allChildren = mediaAddButton.querySelectorAll('*'); allChildren.forEach(child => { child.style.pointerEvents = 'auto'; if (child.tagName === 'BUTTON' || child.classList.contains('add-icon')) { child.style.cursor = 'pointer'; } }); console.log('[FORCE] Media button protection applied'); } }; // Final guard: ensure drawing is ready on Bible tabs after pointer-events updates try { ensureBibleDrawingReady(); } catch (_) {} console.log(`?? CANVAS POINTER EVENTS - Updated all canvases: ${selectModeActive ? 'DISABLED (select mode)' : 'ENABLED (drawing mode)'}`); // Add a debug function to check media item conflicts window.debugMediaItemConflicts = () => { console.log('=== DEBUGGING MEDIA ITEM CONFLICTS ==='); 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, cursor: item.style.cursor, zIndex: item.style.zIndex, className: item.className, id: item.id }); // Check if there are any annotation canvases overlapping const annotationCanvases = document.querySelectorAll('.annotation-canvas'); annotationCanvases.forEach((canvas, canvasIndex) => { const itemRect = item.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect(); if (itemRect.left < canvasRect.right && itemRect.right > canvasRect.left && itemRect.top < canvasRect.bottom && itemRect.bottom > canvasRect.top) { console.log(`?? Media item ${index} overlaps with annotation canvas ${canvasIndex}`); console.log(` Canvas pointer events: ${canvas.style.pointerEvents}`); } }); }); // Check if select mode is interfering console.log('Select mode active:', selectModeActive); console.log('Current tool:', currentTool); }; // Add periodic media button protection for media tabs (reduced frequency) const currentActiveTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); if (currentActiveTab && currentActiveTab.mode === 'media') { // Set up periodic protection for media tabs (less frequent to reduce console noise) if (!window.mediaButtonProtectionInterval) { window.mediaButtonProtectionInterval = setInterval(() => { if (document.visibilityState && document.visibilityState !== 'visible') return; if (typeof window.forceMediaButtonProtection === 'function') { window.forceMediaButtonProtection(); } }, 5000); // Check every 5 seconds instead of every second } } else { // Clear interval if not in media mode if (window.mediaButtonProtectionInterval) { clearInterval(window.mediaButtonProtectionInterval); window.mediaButtonProtectionInterval = null; } } // Add a simple test function for the media button window.testMediaButtonClick = () => { console.log('[TEST] Testing media button click...'); const mediaAddButton = document.getElementById('refreshMediaBtn'); if (mediaAddButton) { console.log('[TEST] Media button found, triggering click...'); mediaAddButton.click(); console.log('[TEST] Media button click triggered'); } else { console.log('[TEST] Media button not found'); } }; // Add a direct file picker test that bypasses all event handling window.testDirectFilePicker = () => { console.log('[DIRECT] Testing direct file picker...'); try { const newInput = document.createElement('input'); newInput.type = 'file'; newInput.multiple = true; newInput.accept = 'image/*,video/*,audio/*,.pdf'; newInput.style.display = 'none'; newInput.addEventListener('change', (e) => { console.log('[DIRECT] Files selected:', e.target.files); if (window.localMediaHandler && window.localMediaHandler.handleFileSelection) { window.localMediaHandler.handleFileSelection(e.target.files); } document.body.removeChild(newInput); }); document.body.appendChild(newInput); console.log('[DIRECT] File input created and added to DOM'); newInput.click(); console.log('[DIRECT] File input click triggered'); } catch (error) { console.error('[DIRECT] Error:', error); } }; } // Safety net: ensure Bible drawing is always ready when a passage is visible function ensureBibleDrawingReady() { try { const tabId = getActiveTabId(); if (!tabId) return; const tab = window.bibleApp?.tabs?.find(t => t.id === tabId); if (!tab || tab.mode !== 'bible') return; // Reset any stuck multi-touch state that blocks drawing try { if (typeof twoFingerScroll !== 'undefined') { twoFingerScroll.active = false; twoFingerScroll.scrollTarget = null; } globalMultiTouchActive = false; } catch (_) {} // Ensure canvases exist and are sized const canvas = getOrCreateAnnotationCanvas(tabId); if (!canvas) return; // Ensure pointer-events reflect current tool state const isDrawingTool = (window.currentTool === 'pen' || window.currentTool === 'highlight' || window.currentTool === 'erase'); canvas.style.pointerEvents = isDrawingTool && !window.selectModeActive ? 'auto' : 'none'; if (typeof updateCanvasPointerEvents === 'function') updateCanvasPointerEvents(); // Restore expected stacking order in case other flows changed it canvas.style.zIndex = '2'; const readingArea = document.getElementById(`readingArea${tabId}`); const overlay = readingArea ? readingArea.querySelector('.annotation-overlay-canvas') : null; if (overlay) { overlay.style.zIndex = '3'; overlay.style.pointerEvents = 'none'; } // Ensure listeners are attached attachAnnotationListeners(tabId); } catch (_) {} } // Lightweight UI self-heal on resume/visibility return function restoreUIIfEmptyOrHidden() { try { const appRef = window.bibleApp; if (!appRef) return; const activeTabId = appRef.activeTabId || (appRef.tabs && appRef.tabs[0] && appRef.tabs[0].id); const tab = appRef.tabs && appRef.tabs.find(t => t.id === activeTabId); if (!tab) return; // If main content is empty, re-render the active tab UI const tabContent = document.querySelector('.tab-content'); const panelMissing = !tabContent || !tabContent.querySelector('.tab-panel'); if (panelMissing) { try { appRef.renderTabs && appRef.renderTabs(); } catch (_) {} try { appRef.renderTabContent && appRef.renderTabContent(tab); } catch (_) {} } // If Bible/Alpha text was temporarily hidden for auto-jump, ensure it becomes visible again if (tab.mode === 'bible' || tab.mode === 'alpha') { const bt = document.getElementById(`bibleText${tab.id}`); const headerEl = document.getElementById(`chapterHeader${tab.id}`); const readingAreaEl = document.getElementById(`readingArea${tab.id}`); if (bt && (bt.style && (bt.style.visibility === 'hidden' || bt.style.opacity === '0'))) { try { bt.style.visibility = ''; } catch (_) {} try { bt.style.opacity = '1'; } catch (_) {} } // If content was loaded previously, ensure containers are visible if (tab.loaded) { if (headerEl && headerEl.style && headerEl.style.display === 'none') { headerEl.style.display = 'block'; } if (readingAreaEl && readingAreaEl.style && readingAreaEl.style.display === 'none') { readingAreaEl.style.display = 'block'; } } // If a pending verse jump exists, retry once to finalize reveal/position if (tab._scrollToVerse && typeof appRef.autoScrollToSelectedVerse === 'function') { try { appRef.autoScrollToSelectedVerse(tab.id); } catch (_) {} } } } catch (_) {} } // Heal when returning to the app try { window.addEventListener('focus', () => { try { restoreUIIfEmptyOrHidden(); } catch (_) {} }, { capture: true }); document.addEventListener('visibilitychange', () => { if (!document.hidden) { try { restoreUIIfEmptyOrHidden(); } catch (_) {} } }, { capture: true }); } catch (_) {} // Auto-heal: re-arm Bible drawing after focus/visibility changes and periodically try { window.addEventListener('focus', () => { try { ensureBibleDrawingReady(); } catch (_) {} }, { capture: true }); document.addEventListener('visibilitychange', () => { if (!document.hidden) { try { ensureBibleDrawingReady(); } catch (_) {} } }, { capture: true }); if (!window.bibleToolMonitor) { window.bibleToolMonitor = setInterval(() => { if (document.visibilityState && document.visibilityState !== 'visible') return; try { ensureBibleDrawingReady(); } catch (_) {} }, 5000); // Stop after 5 minutes to avoid permanent background work setTimeout(() => { try { clearInterval(window.bibleToolMonitor); window.bibleToolMonitor = null; } catch (_) {} }, 5 * 60 * 1000); } } catch (_) {} // Disable word selection functionality function disableWordSelection() { console.log('Word selection mode disabled'); selectModeActive = false; window.selectModeActive = false; // CRITICAL: Sync global state // Remove visual indicators document.body.classList.remove('select-mode-active'); // Reset cursor const activeTabId = getActiveTabId(); const readingArea = document.getElementById(`readingArea${activeTabId}`); if (readingArea) { readingArea.style.cursor = ''; // Extra safety: clear any native selection that might have occurred try { const sel = window.getSelection && window.getSelection(); if (sel && sel.removeAllRanges) sel.removeAllRanges(); if (document.activeElement) document.activeElement.blur(); } catch (_) {} } // Update canvas pointer events for all tabs updateCanvasPointerEvents(); // Clean up any active double-tap timers if (activeTabId) { const bibleText = document.getElementById(`bibleText${activeTabId}`); if (bibleText) { const clickableWords = bibleText.querySelectorAll('.clickable-word'); clickableWords.forEach(wordSpan => { if (wordSpan._touchTapState && wordSpan._touchTapState.tapTimeout) { clearTimeout(wordSpan._touchTapState.tapTimeout); wordSpan._touchTapState.tapTimeout = null; } wordSpan.style.backgroundColor = ''; }); } } // NOTE: Popups should persist across tab switches - only close when explicitly requested // Removed closeWordPopup() call - popups will be hidden/shown per tab instead console.log('[DISABLE WORD SELECTION] Complete cleanup finished'); } // --- Hebrew Popup Helpers (WLCa + BDBT) --- let _wlcaIndex = null; let _ntgtIndex = null; let _ntGreekInterlinearIndex = null; let _greekStrongsIndex = null; let _bdbtIndex = null; let _bdbtTopicIndex = null; function normalizeHebrewWord(word) { return String(word || '') .normalize('NFKD') .replace(/\p{M}/gu, '') .replace(/[^\p{L}\p{N}]/gu, '') .trim(); } function normalizeEnglishWord(word) { return String(word || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '') .trim(); } function hasGreekText(text) { return /[\u0370-\u03FF\u1F00-\u1FFF]/.test(String(text || '')); } function normalizePopupTestament(popup) { if (!popup) return; try { const labels = popup.querySelectorAll('.word-info-label'); const values = popup.querySelectorAll('.word-info-value'); if (labels.length < 2 || values.length < 2) return; // Check if this is NT by looking at the popup title/reference let isNT = false; const titleEl = popup.querySelector('.word-info-title'); if (titleEl) { const titleText = titleEl.textContent || ''; const refMatch = titleText.match(/�\s*([^�]+)/); if (refMatch) { const bookName = refMatch[1].trim().split(/\s+/)[0]; const bookNum = getBookNumberForName(bookName); if (bookNum && bookNum >= 40) { isNT = true; } } } // Also check for Greek text in source const sourceText = values[0].textContent || ''; const hasGreek = hasGreekText(sourceText); // Update if it's NT (either by book number or Greek text detection) if (isNT || hasGreek) { labels[0].textContent = 'NTGT'; labels[1].textContent = "Strong's Greek"; values[0].classList.remove('hebrew'); values[0].classList.add('greek'); values[0].setAttribute('lang', 'el'); values[0].setAttribute('dir', 'ltr'); values[1].classList.remove('hebrew'); values[1].classList.add('greek', 'bdbt'); if (values[1].innerHTML) { values[1].innerHTML = values[1].innerHTML .replace(/BDBT/gi, "Strong's Greek") .replace(/No Greek dictionary entry found/gi, 'No Strong\'s Greek entry found'); } } } catch (_) {} } function getBookNameCandidates(bookName) { const normalized = String(bookName || '').trim().toLowerCase(); const candidates = [normalized]; if (normalized === 'psalms') candidates.push('psalm'); if (normalized === 'psalm') candidates.push('psalms'); if (normalized === 'song of solomon') candidates.push('song of songs'); if (normalized === 'song of songs') candidates.push('song of solomon'); return Array.from(new Set(candidates)).filter(Boolean); } const NT_BOOK_ABBREV_MAP = { Mat: 'Matthew', Mar: 'Mark', Luk: 'Luke', Joh: 'John', Act: 'Acts', Rom: 'Romans', '1Co': '1 Corinthians', '2Co': '2 Corinthians', Gal: 'Galatians', Eph: 'Ephesians', Phi: 'Philippians', Php: 'Philippians', Phl: 'Philippians', Col: 'Colossians', '1Th': '1 Thessalonians', '2Th': '2 Thessalonians', '1Ti': '1 Timothy', '2Ti': '2 Timothy', Tit: 'Titus', Phm: 'Philemon', Heb: 'Hebrews', Jam: 'James', Jas: 'James', '1Pe': '1 Peter', '2Pe': '2 Peter', '1Jo': '1 John', '2Jo': '2 John', '3Jo': '3 John', Jud: 'Jude', Rev: 'Revelation' }; function parseNTReference(reference) { const ref = String(reference || '').trim(); if (!ref) return null; const match = ref.match(/^([1-3]?[A-Za-z]+)\.(\d+)\.(\d+)\.(\d+)$/); if (!match) return null; return { bookAbbrev: match[1], chapter: Number(match[2]), verse: Number(match[3]), wordIndex: Number(match[4]) }; } function getPopupBookName(wordInfo, event) { try { const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); if (activeTab?.book) return activeTab.book; } catch (_) {} const ref = wordInfo && wordInfo.reference ? String(wordInfo.reference) : ''; const m = ref.match(/^([^0-9:]+)\s+\d/); return m ? m[1].trim() : ''; } function getBookNameFromAbbrev(abbrev) { const key = String(abbrev || '').trim(); return NT_BOOK_ABBREV_MAP[key] || NT_BOOK_ABBREV_MAP[key.replace(/\s+/g, '')] || null; } function getBookNumberForName(bookName) { try { const keys = Object.keys(bibleStructure || {}); if (!keys.length) return null; const candidates = getBookNameCandidates(bookName); for (const name of candidates) { const idx = keys.findIndex(k => k.trim().toLowerCase() === name); if (idx >= 0) return idx + 1; } } catch (_) {} return null; } function parseWLCaVerseTokens(verseText) { const text = String(verseText || ''); const tokens = []; const regex = /([^<]+?)(\d+)<\/S>/g; let match; let lastIndex = 0; while ((match = regex.exec(text)) !== null) { const raw = match[1] || ''; const strong = match[2] || ''; tokens.push({ raw, text: raw.trim(), strong }); lastIndex = regex.lastIndex; } const tail = text.slice(lastIndex); if (tail) { tokens.push({ raw: tail.replace(/\d+<\/S>/g, ''), text: tail.trim(), strong: null }); } return tokens; } function parsePlainVerseTokens(verseText) { const text = String(verseText || ''); const parts = text.split(/(\s+)/); return parts.filter(part => part !== '').map(part => { if (/^\s+$/.test(part)) { return { raw: part, text: '', isWord: false }; } const core = part.replace(/[^\p{L}\p{N}]+/gu, '').trim(); return { raw: part, text: core, isWord: !!core }; }); } function buildVerseHtmlFromTokens(tokens, highlightIndex, hitClass) { const klass = hitClass || 'wlca-hit'; return tokens.map((token, idx) => { const raw = token.raw || ''; if (!raw) return ''; if (idx !== highlightIndex) return escapeHtml(raw); const leading = (raw.match(/^\s*/)?.[0]) || ''; const trailing = (raw.match(/\s*$/)?.[0]) || ''; const core = raw.trim(); if (!core) return escapeHtml(raw); return `${leading}${escapeHtml(core)}${trailing}`; }).join(''); } function buildWLCaHtmlFromTokens(tokens, highlightIndex) { return buildVerseHtmlFromTokens(tokens, highlightIndex, 'wlca-hit'); } function buildGreekHtmlFromTokens(tokens, highlightTokenIndex) { if (!tokens || !tokens.length) return ''; const htmlTokens = []; tokens.forEach((t, idx) => { htmlTokens.push({ raw: t.text || '', text: t.text || '', isWord: true }); if (idx < tokens.length - 1) { htmlTokens.push({ raw: ' ', text: '', isWord: false }); } }); const highlightIndex = highlightTokenIndex != null ? (highlightTokenIndex * 2) : null; return buildVerseHtmlFromTokens(htmlTokens, highlightIndex, 'ntgt-hit'); } async function loadWLCaIndex() { if (_wlcaIndex) return _wlcaIndex; const paths = [ 'Translations/WLCa.json', './Translations/WLCa.json', 'Translations/WLCA.json', './Translations/WLCA.json', 'WLCa.json', './WLCa.json', 'WLCA.json', './WLCA.json' ]; let data = null; let lastErr = null; for (const p of paths) { try { const resp = await fetch(p, { cache: 'no-cache' }); if (resp.ok) { data = await resp.json(); break; } } catch (e) { lastErr = e; } } if (!Array.isArray(data)) { throw lastErr || new Error('Failed to load WLCa translation data'); } const index = new Map(); data.forEach(row => { if (!row || row.book == null || row.chapter == null || row.verse == null) return; const bookNum = Number(row.book); const chapter = Number(row.chapter); const verse = Number(row.verse); const text = row.text != null ? String(row.text) : ''; if (!bookNum || !chapter || !verse) return; const key = `${bookNum}|${chapter}|${verse}`; if (!index.has(key)) index.set(key, text); }); _wlcaIndex = index; return _wlcaIndex; } async function getWLCaVerseText(bookName, chapterNumber, verseNumber) { const index = await loadWLCaIndex(); const chapter = Number(chapterNumber); const verse = Number(verseNumber); if (!chapter || !verse) return null; const bookNum = getBookNumberForName(bookName); if (!bookNum) return null; const key = `${bookNum}|${chapter}|${verse}`; if (index.has(key)) return index.get(key); return null; } async function loadNTGTIndex() { if (_ntgtIndex) return _ntgtIndex; const paths = [ 'Translations/NTGT.json', './Translations/NTGT.json', 'NTGT.json', './NTGT.json' ]; let data = null; let lastErr = null; for (const p of paths) { try { const resp = await fetch(p, { cache: 'no-cache' }); if (resp.ok) { data = await resp.json(); break; } } catch (e) { lastErr = e; } } if (!Array.isArray(data)) { throw lastErr || new Error('Failed to load NTGT translation data'); } const index = new Map(); data.forEach(row => { if (!row || row.book == null || row.chapter == null || row.verse == null) return; const bookNum = Number(row.book); const chapter = Number(row.chapter); const verse = Number(row.verse); const text = row.text != null ? String(row.text) : ''; if (!bookNum || !chapter || !verse) return; const key = `${bookNum}|${chapter}|${verse}`; if (!index.has(key)) index.set(key, text); }); _ntgtIndex = index; return _ntgtIndex; } async function getNTGTVerseText(bookName, chapterNumber, verseNumber) { const index = await loadNTGTIndex(); const chapter = Number(chapterNumber); const verse = Number(verseNumber); if (!chapter || !verse) return null; const bookNum = getBookNumberForName(bookName); if (!bookNum) return null; const key = `${bookNum}|${chapter}|${verse}`; if (index.has(key)) return index.get(key); return null; } async function loadNTGreekInterlinearIndex() { if (_ntGreekInterlinearIndex) return _ntGreekInterlinearIndex; const paths = [ 'Dictionary/NTGT_interlinear.jsonl', './Dictionary/NTGT_interlinear.jsonl', 'Translations/NTGT_interlinear.jsonl', './Translations/NTGT_interlinear.jsonl', 'NTGT_interlinear.jsonl', './NTGT_interlinear.jsonl' ]; let text = null; let lastErr = null; for (const p of paths) { try { const resp = await fetch(p, { cache: 'no-cache' }); if (resp.ok) { text = await resp.text(); break; } } catch (e) { lastErr = e; } } if (!text) throw lastErr || new Error('Failed to load NT Greek interlinear data'); const index = new Map(); const lines = text.split(/\r?\n/).filter(Boolean); for (const line of lines) { let row = null; try { row = JSON.parse(line); } catch (_) { continue; } if (!row || !row.reference) continue; const parsed = parseNTReference(row.reference); if (!parsed || !parsed.bookAbbrev) continue; const bookName = getBookNameFromAbbrev(parsed.bookAbbrev); if (!bookName) continue; const bookNum = getBookNumberForName(bookName); if (!bookNum) continue; const key = `${bookNum}|${parsed.chapter}|${parsed.verse}`; const entry = { wordIndex: parsed.wordIndex, text: String(row.text || '').trim(), transliteration: String(row.transliteration || '').trim(), translation: String(row.translation || '').trim(), dStrongs: String(row.dStrongs || '').trim(), manuscript: String(row.manuscript_source || '').trim() }; if (!index.has(key)) index.set(key, []); index.get(key).push(entry); } for (const [key, arr] of index.entries()) { arr.sort((a, b) => (a.wordIndex || 0) - (b.wordIndex || 0)); } _ntGreekInterlinearIndex = index; return _ntGreekInterlinearIndex; } async function getNTGreekInterlinearTokens(bookName, chapterNumber, verseNumber) { const index = await loadNTGreekInterlinearIndex(); const chapter = Number(chapterNumber); const verse = Number(verseNumber); if (!chapter || !verse) return null; const bookNum = getBookNumberForName(bookName); if (!bookNum) return null; const key = `${bookNum}|${chapter}|${verse}`; return index.get(key) || null; } function parseGreekStrongs(dStrongs) { const str = String(dStrongs || ''); const match = str.match(/G\d{3,5}/); return match ? match[0] : null; } async function loadBdbtIndex() { if (_bdbtIndex) return _bdbtIndex; const paths = [ 'Dictionary/BDBT.json', './Dictionary/BDBT.json', 'BDBT.json', './BDBT.json' ]; let data = null; let lastErr = null; for (const p of paths) { try { const resp = await fetch(p, { cache: 'no-cache' }); if (resp.ok) { data = await resp.json(); break; } } catch (e) { lastErr = e; } } if (!Array.isArray(data)) { throw lastErr || new Error('Failed to load BDBT dictionary data'); } const index = new Map(); const topicIndex = new Map(); data.forEach(entry => { const lexeme = normalizeHebrewWord(entry && entry.lexeme); if (!lexeme) return; if (!index.has(lexeme)) index.set(lexeme, []); index.get(lexeme).push(entry); const topic = String(entry && entry.topic || '').toUpperCase(); if (topic && !topicIndex.has(topic)) topicIndex.set(topic, entry); }); _bdbtIndex = index; _bdbtTopicIndex = topicIndex; return _bdbtIndex; } async function getBdbtEntry(tappedWord, strongNumber = null) { await loadBdbtIndex(); if (strongNumber) { const topicKeyH = `H${strongNumber}`; const topicKeyG = `G${strongNumber}`; const byTopic = _bdbtTopicIndex.get(topicKeyH) || _bdbtTopicIndex.get(topicKeyG); if (byTopic) return byTopic; } const normalized = normalizeHebrewWord(tappedWord); if (!normalized) return null; const entries = _bdbtIndex.get(normalized); return entries && entries.length ? entries[0] : null; } function formatBdbtEntryHtml(entry, fallbackWord) { if (!entry) { return `
No dictionary entry found for ${escapeHtml(fallbackWord || '')}.
`; } const lexeme = entry.lexeme || fallbackWord || ''; let html = ''; // Process definition HTML to remove fields that are toggled OFF and remove hyperlinks let definitionHtml = entry.definition || ''; if (definitionHtml && typeof document !== 'undefined') { try { const tempDiv = document.createElement('div'); tempDiv.innerHTML = definitionHtml; // Remove all anchor tags (hyperlinks) - replace with their text content const anchorTags = tempDiv.querySelectorAll('a'); anchorTags.forEach(anchor => { const text = anchor.textContent || anchor.innerText; const textNode = document.createTextNode(text); anchor.parentNode.replaceChild(textNode, anchor); }); // Remove "Original" text if toggle is OFF if (!isPopupSectionVisible('ot_original')) { // Remove elements with Hebrew text (lexeme) const allElements = tempDiv.querySelectorAll('*'); allElements.forEach(el => { const text = el.textContent.trim(); // Check if element contains only Hebrew text (lexeme) or "Original:" label if ((text === lexeme && /[\u0590-\u05FF]/.test(text)) || text.includes('Original:') || (el.classList.contains('bdbt-lexeme') && text === lexeme)) { el.remove(); } }); // Also check direct text content if (tempDiv.textContent.includes('Original:') || tempDiv.textContent.trim() === lexeme) { const textNodes = Array.from(tempDiv.childNodes).filter(n => n.nodeType === Node.TEXT_NODE); textNodes.forEach(tn => { if (tn.textContent.includes('Original:') || tn.textContent.trim() === lexeme) { tn.remove(); } }); } } // Remove "Transliteration" if toggle is OFF if (!isPopupSectionVisible('ot_transliteration')) { const allElements = tempDiv.querySelectorAll('*'); allElements.forEach(el => { const text = el.textContent.trim(); if (text.includes('Transliteration:') || (entry.transliteration && text === entry.transliteration && (el.previousSibling?.textContent?.includes('Transliteration:') || el.parentElement?.textContent?.includes('Transliteration:')))) { el.remove(); } }); } // Remove "Phonetic" if toggle is OFF if (!isPopupSectionVisible('ot_phonetic')) { const allElements = tempDiv.querySelectorAll('*'); allElements.forEach(el => { const text = el.textContent.trim(); if (text.includes('Phonetic:') || (entry.phonetic && text === entry.phonetic && (el.previousSibling?.textContent?.includes('Phonetic:') || el.parentElement?.textContent?.includes('Phonetic:')))) { el.remove(); } }); } // Remove "Pronunciation" if toggle is OFF if (!isPopupSectionVisible('ot_pronunciation')) { const allElements = tempDiv.querySelectorAll('*'); allElements.forEach(el => { const text = el.textContent.trim(); if (text.includes('Pronunciation:') || (entry.pronunciation && text === entry.pronunciation && (el.previousSibling?.textContent?.includes('Pronunciation:') || el.parentElement?.textContent?.includes('Pronunciation:')))) { el.remove(); } }); } definitionHtml = tempDiv.innerHTML; } catch (e) { console.warn('Error processing definition HTML:', e); // Fall back to original if parsing fails } } // Original text (lexeme) - only add if toggle is ON and not already in definition if (isPopupSectionVisible('ot_original')) { const definitionContainsLexeme = definitionHtml && (definitionHtml.includes(lexeme) || definitionHtml.includes('Original:')); if (!definitionContainsLexeme) { html += `
${escapeHtml(lexeme)}
`; } } // Transliteration - only add if toggle is ON and not already in definition if (isPopupSectionVisible('ot_transliteration') && entry.transliteration) { const definitionContainsTranslit = definitionHtml && (definitionHtml.includes(entry.transliteration) || definitionHtml.includes('Transliteration:')); if (!definitionContainsTranslit) { html += `
Transliteration: ${escapeHtml(entry.transliteration)}
`; } } // Pronunciation - only add if toggle is ON and not already in definition if (isPopupSectionVisible('ot_pronunciation') && entry.pronunciation) { const definitionContainsPron = definitionHtml && (definitionHtml.includes(entry.pronunciation) || definitionHtml.includes('Pronunciation:')); if (!definitionContainsPron) { html += `
Pronunciation: ${escapeHtml(entry.pronunciation)}
`; } } // Phonetic - only add if toggle is ON and not already in definition if (isPopupSectionVisible('ot_phonetic') && entry.phonetic) { const definitionContainsPhonetic = definitionHtml && (definitionHtml.includes(entry.phonetic) || definitionHtml.includes('Phonetic:')); if (!definitionContainsPhonetic) { html += `
Phonetic: ${escapeHtml(entry.phonetic)}
`; } } // Short Definition if (isPopupSectionVisible('ot_short_definition') && entry.short_definition) { html += `
Short Definition: ${escapeHtml(entry.short_definition)}
`; } // BDB Definition - add processed HTML if (isPopupSectionVisible('ot_bdb_definition') && definitionHtml) { html += `
${definitionHtml}
`; } // If entry exists but no fields are visible (all toggles off), return empty string // Only return missing message if entry doesn't exist if (!entry) { return `
No dictionary entry found for ${escapeHtml(fallbackWord || '')}.
`; } return html; // Return empty string if all toggles are off but entry exists } async function loadStrongsGreekDictionary() { if (_greekStrongsIndex) return _greekStrongsIndex; const paths = [ 'Dictionary/strongs-greek-dictionary.json', './Dictionary/strongs-greek-dictionary.json', 'strongs-greek-dictionary.json', './strongs-greek-dictionary.json' ]; let data = null; let lastErr = null; for (const p of paths) { try { const resp = await fetch(p, { cache: 'no-cache' }); if (resp.ok) { const jsonData = await resp.json(); // Only use if it has actual entries if (jsonData && typeof jsonData === 'object' && Object.keys(jsonData).length > 0) { data = jsonData; console.log(`[Greek Dictionary] Loaded from ${p} with ${Object.keys(data).length} entries`); break; } else { console.warn(`[Greek Dictionary] Empty dictionary from ${p}`); } } else { console.warn(`[Greek Dictionary] Failed to load from ${p}: ${resp.status} ${resp.statusText}`); } } catch (e) { console.warn(`[Greek Dictionary] Error loading from ${p}:`, e); lastErr = e; } } if (!data || typeof data !== 'object' || Object.keys(data).length === 0) { const errorMsg = lastErr ? lastErr.message : 'Dictionary JSON file is empty or not found'; console.error('[Greek Dictionary] Failed to load:', errorMsg); throw lastErr || new Error('Failed to load Strong\'s Greek dictionary data from JSON file'); } _greekStrongsIndex = data; return _greekStrongsIndex; } async function getGreekStrongsEntry(strongs) { if (!strongs) return null; try { const index = await loadStrongsGreekDictionary(); if (!index || typeof index !== 'object') { console.warn('[Greek Dictionary] Index is not a valid object:', typeof index); return null; } const entry = index[strongs] || null; if (!entry) { console.warn(`[Greek Dictionary] No entry found for ${strongs}. Available keys sample:`, Object.keys(index).slice(0, 5)); } return entry; } catch (error) { console.error('[Greek Dictionary] Error loading entry:', error); return null; } } function formatGreekDictionaryHtml(entry, token, strongs, morph) { const greekWord = token && token.text ? token.text : ''; if (!entry) { const label = strongs ? ` ${escapeHtml(strongs)}` : ''; return `
No Greek dictionary entry found${label}.
`; } const lemma = entry.lemma || greekWord || ''; let html = ''; // Original text (lemma) if (isPopupSectionVisible('nt_original')) { html += `
${escapeHtml(lemma)}
`; } // Transliteration if (isPopupSectionVisible('nt_transliteration') && entry.translit) { html += `
Transliteration: ${escapeHtml(entry.translit)}
`; } // Strong's Number if (isPopupSectionVisible('nt_strongs_number') && strongs) { html += `
Strong's: ${escapeHtml(strongs)}
`; } // Morphology if (isPopupSectionVisible('nt_morphology') && morph) { html += `
Morphology: ${escapeHtml(morph)}
`; } // Interlinear if (isPopupSectionVisible('nt_interlinear') && token && token.translation) { html += `
Interlinear: ${escapeHtml(token.translation)}
`; } // Strong's Definition - remove hyperlinks before adding if (isPopupSectionVisible('nt_strongs_definition') && entry.strongs_def) { let strongsDefHtml = entry.strongs_def; // Remove anchor tags from Strong's definition HTML if (typeof document !== 'undefined') { try { const tempDiv = document.createElement('div'); tempDiv.innerHTML = strongsDefHtml; // Remove all anchor tags (hyperlinks) - replace with their text content const anchorTags = tempDiv.querySelectorAll('a'); anchorTags.forEach(anchor => { const text = anchor.textContent || anchor.innerText; const textNode = document.createTextNode(text); anchor.parentNode.replaceChild(textNode, anchor); }); strongsDefHtml = tempDiv.innerHTML; } catch (e) { // If parsing fails, use escaped version strongsDefHtml = escapeHtml(entry.strongs_def); } } else { // Fallback: use regex to remove anchor tags if DOM is not available strongsDefHtml = entry.strongs_def.replace(/]*>([^<]*)<\/a>/gi, '$1'); } html += `
${strongsDefHtml}
`; } // KJV Gloss if (isPopupSectionVisible('nt_kjv_gloss') && entry.kjv_def) { html += `
KJV Gloss: ${escapeHtml(entry.kjv_def)}
`; } // Derivation if (isPopupSectionVisible('nt_derivation') && entry.derivation) { html += `
Derivation: ${escapeHtml(entry.derivation)}
`; } // If entry exists but no fields are visible (all toggles off), return empty string // Only return missing message if entry doesn't exist if (!entry) { const label = strongs ? ` ${escapeHtml(strongs)}` : ''; return `
No Greek dictionary entry found${label}.
`; } return html; // Return empty string if all toggles are off but entry exists } // Handle word click/tap async function handleWordClick(word, event) { /* * ONE double-tap must open ONE card. * * Three separate paths can land here for a single tap: the per-word .clickable-word listener, the * reading-area delegate, and its pointer-event fallback. Each of those has its own timing guard, * but a path that misses one still slips through — which is how a double-tap produced the real * card PLUS an empty "No information available" one. Gate it here instead, at the single point * every path funnels through, so no future caller can reintroduce the duplicate. */ try { const tapTs = Date.now(); if (window.__wordPopupGateTs && (tapTs - window.__wordPopupGateTs) < 500) return; window.__wordPopupGateTs = tapTs; } catch (_) {} // Suppress during multi-touch/scroll grace period if (typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) { return; } if (window.globalMultiTouchActive) { return; } // Prevent default behavior try { event.preventDefault(); event.stopPropagation(); } catch (_) {} try { event.__wordPopupHandled = true; } catch (_) {} try { window.__lastWordPopupTs = Date.now(); } catch (_) {} try { const wordSpan = event.target; const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId); const book = activeTab?.book || ''; const chapter = activeTab?.chapter || ''; // Determine verse number from DOM where possible let verseNumber = null; try { const verseEl = wordSpan && wordSpan.closest ? wordSpan.closest('.verse') : null; if (verseEl) { const dataVerse = verseEl.getAttribute('data-verse'); if (dataVerse) verseNumber = Number(dataVerse); if (!verseNumber) { const numEl = verseEl.querySelector('.verse-number'); if (numEl) verseNumber = Number(numEl.textContent || '0'); } } } catch (_) {} if (!verseNumber) verseNumber = Number(activeTab?.verse || 0) || 1; const reference = book && chapter ? `${book} ${chapter}:${verseNumber}` : ''; const now = Date.now(); window.__popupInFlight = window.__popupInFlight || new Map(); const inflightKey = `${word}|${book}|${chapter}|${verseNumber}`; const inflightAt = window.__popupInFlight.get(inflightKey); if (inflightAt && now - inflightAt < 2000) { return; } window.__popupInFlight.set(inflightKey, now); setTimeout(() => { try { window.__popupInFlight.delete(inflightKey); } catch (_) {} }, 2500); // Pull the same verse from WLCa (OT) or NT Greek interlinear (NT) and underline the matching token const bookNum = getBookNumberForName(book); const isNT = !!bookNum && bookNum >= 40; const testament = isNT ? 'NT' : 'OT'; const sourceLabel = isNT ? 'NTGT' : 'WLCa'; const sourceLang = isNT ? 'el' : 'he'; const sourceDir = isNT ? 'ltr' : 'rtl'; const sourceClass = isNT ? 'greek' : 'hebrew'; let sourceHtml = isNT ? 'Unable to load NTGT verse.' : 'Unable to load WLCa verse.'; let hebrewToken = null; let greekToken = null; let dictionaryLabel = 'BDBT'; let dictionaryClass = 'hebrew bdbt'; let dictionaryHtml = ''; try { if (isNT) { const interlinearTokens = await getNTGreekInterlinearTokens(book, chapter, verseNumber); if (interlinearTokens && interlinearTokens.length) { const verseEl = wordSpan && wordSpan.closest ? wordSpan.closest('.verse') : null; const englishSpans = verseEl ? Array.from(verseEl.querySelectorAll('.clickable-word')) : []; const englishIndex = englishSpans.indexOf(wordSpan); const englishCount = englishSpans.length; const targetNorm = normalizeEnglishWord(word); const ratio = (englishIndex >= 0 && englishCount > 1 && interlinearTokens.length > 1) ? englishIndex / (englishCount - 1) : null; /* * Match the tapped word against the token's translation. A token's translation is often a * PHRASE ("were commended"), and the English word is often a different form of it * ("commendation") — comparing the whole string exactly matched neither, so the code fell * through to guessing by position and highlighted the wrong Greek word. Try exact whole * translation, then any word of it, then stems of those words. */ const targetStem = __ilStem(word); const translationWords = (t) => String(t && t.translation || '') .toLowerCase().replace(/[\[\]<>().,;:!?"“”]/g, ' ').split(/\s+/) .map(normalizeEnglishWord).filter(Boolean); let candidates = []; if (targetNorm) { candidates = interlinearTokens.filter(t => normalizeEnglishWord(t.translation) === targetNorm); if (!candidates.length) { candidates = interlinearTokens.filter(t => translationWords(t).includes(targetNorm)); } if (!candidates.length && targetStem) { candidates = interlinearTokens.filter(t => translationWords(t).some(x => __ilStem(x) === targetStem)); } } if (candidates.length) { if (ratio != null) { const targetIdx = Math.round(ratio * (interlinearTokens.length - 1)); greekToken = candidates.reduce((best, t) => { const idx = interlinearTokens.indexOf(t); const bestIdx = interlinearTokens.indexOf(best); return Math.abs(idx - targetIdx) < Math.abs(bestIdx - targetIdx) ? t : best; }, candidates[0]); } else { greekToken = candidates[0]; } } if (!greekToken) { if (ratio != null) { const mapped = Math.round(ratio * (interlinearTokens.length - 1)); greekToken = interlinearTokens[Math.max(0, Math.min(mapped, interlinearTokens.length - 1))] || interlinearTokens[0]; } else { greekToken = interlinearTokens[0]; } } const highlightIndex = greekToken ? interlinearTokens.indexOf(greekToken) : 0; sourceHtml = buildGreekHtmlFromTokens(interlinearTokens, highlightIndex); const strongs = parseGreekStrongs(greekToken && greekToken.dStrongs); const morph = greekToken && greekToken.dStrongs && greekToken.dStrongs.includes('=') ? greekToken.dStrongs.split('=').slice(1).join('=').trim() : ''; const dictEntry = strongs ? await getGreekStrongsEntry(strongs) : null; dictionaryLabel = 'Strong\'s Greek'; dictionaryClass = 'greek bdbt'; dictionaryHtml = formatGreekDictionaryHtml(dictEntry, greekToken, strongs, morph); } else { const ntgtVerseText = await getNTGTVerseText(book, chapter, verseNumber); if (ntgtVerseText) { const tokens = parsePlainVerseTokens(ntgtVerseText); const wordTokens = tokens.map((t, idx) => (t.isWord ? { ...t, idx } : null)).filter(Boolean); const wordCount = wordTokens.length; let highlightIndex = null; if (wordCount > 0) { const verseEl = wordSpan && wordSpan.closest ? wordSpan.closest('.verse') : null; const englishSpans = verseEl ? Array.from(verseEl.querySelectorAll('.clickable-word')) : []; const englishIndex = englishSpans.indexOf(wordSpan); const englishCount = englishSpans.length; let targetToken = null; if (englishIndex >= 0 && englishCount > 1 && wordCount > 1) { const ratio = englishIndex / (englishCount - 1); const mapped = Math.round(ratio * (wordCount - 1)); targetToken = wordTokens[Math.max(0, Math.min(mapped, wordCount - 1))] || wordTokens[0]; } else { targetToken = wordTokens[0]; } highlightIndex = targetToken ? targetToken.idx : wordTokens[0]?.idx; } sourceHtml = buildVerseHtmlFromTokens(tokens, highlightIndex, 'ntgt-hit'); } dictionaryLabel = 'Strong\'s Greek'; dictionaryClass = 'greek bdbt'; dictionaryHtml = '
No Greek dictionary entry found for this verse.
'; } } else { const wlcaVerseText = await getWLCaVerseText(book, chapter, verseNumber); if (wlcaVerseText) { const tokens = parseWLCaVerseTokens(wlcaVerseText); const wordTokens = tokens.map((t, idx) => ({ ...t, idx })).filter(t => t.text); const hebCount = wordTokens.length; let highlightIndex = null; if (hebCount > 0) { const verseEl = wordSpan && wordSpan.closest ? wordSpan.closest('.verse') : null; const englishSpans = verseEl ? Array.from(verseEl.querySelectorAll('.clickable-word')) : []; const englishIndex = englishSpans.indexOf(wordSpan); const englishCount = englishSpans.length; if (englishIndex >= 0 && englishCount > 1 && hebCount > 1) { const ratio = englishIndex / (englishCount - 1); const mapped = Math.round(ratio * (hebCount - 1)); hebrewToken = wordTokens[Math.max(0, Math.min(mapped, hebCount - 1))] || wordTokens[0]; } else { hebrewToken = wordTokens[0]; } highlightIndex = hebrewToken ? hebrewToken.idx : wordTokens[0]?.idx; } sourceHtml = buildWLCaHtmlFromTokens(tokens, highlightIndex); } let bdbtEntry = null; try { const strongNumber = hebrewToken && hebrewToken.strong ? String(hebrewToken.strong).trim() : null; const lookupWord = hebrewToken && hebrewToken.text ? hebrewToken.text : word; bdbtEntry = await getBdbtEntry(lookupWord, strongNumber); } catch (_) {} dictionaryLabel = 'BDBT'; dictionaryClass = 'hebrew bdbt'; dictionaryHtml = formatBdbtEntryHtml(bdbtEntry, hebrewToken && hebrewToken.text ? hebrewToken.text : word); } } catch (_) {} // Verse-exact original word (STEP interlinear): the tapped word matched to THIS verse's // tagged Hebrew/Greek — replaces the old position-ratio guess with real alignment. let interlinearWord = null; let interlinearAttribution = ''; try { if (isPopupSectionVisible(isNT ? 'nt_il' : 'ot_il') && book && chapter && verseNumber) { const il = await fetchInterlinearVerse(book, chapter, verseNumber); if (il && Array.isArray(il.words) && il.words.length) { interlinearWord = matchInterlinearWord(word, wordSpan, il.words); interlinearAttribution = il.attribution || ''; // Preacher-requested: the word's dictionary DEFINITION and its ORIGIN (component roots // with meanings, e.g. Laodicea = λαός (people) + δίκη (judgment)) right on the card, // looked up by the exact Strong's number (clone — don't mutate the verse cache). if (interlinearWord && interlinearWord.s) { const [def, origin, usage] = await Promise.all([ lookupStrongsDefinition(interlinearWord.s, isNT), lookupStrongsOrigin(interlinearWord.s, isNT), lookupStrongsUsage(interlinearWord.s) ]); // `en` = the English word actually tapped, shown beside the original on the card. interlinearWord = { ...interlinearWord, def, origin, usage, en: String(word || '').replace(/[.,;:!?"'’”]+$/, '') }; } } } } catch (_) {} const popupData = { word: word, reference, wlcaHtml: sourceHtml, sourceHtml, sourceLabel, sourceLang, sourceDir, sourceClass, dictionaryLabel, dictionaryClass, dictionaryHtml, bdbtHtml: dictionaryHtml, popupKey: `${inflightKey}|${testament}`, testament, isNT, interlinear: interlinearWord, interlinearAttribution }; showWordPopup(popupData, event); } catch (error) { console.error('?? WORD CLICK DEBUG - Error in handleWordClick:', error); const fallbackData = { word: word, reference: '', wlcaHtml: 'Unable to load WLCa verse.', sourceHtml: 'Unable to load WLCa verse.', sourceLabel: 'WLCa', sourceLang: 'he', sourceDir: 'rtl', sourceClass: 'hebrew', dictionaryLabel: 'BDBT', dictionaryClass: 'hebrew bdbt', dictionaryHtml: `
Unable to load dictionary entry for ${escapeHtml(word || '')}.
`, bdbtHtml: `
Unable to load dictionary entry for ${escapeHtml(word || '')}.
`, popupKey: `${word}|error` }; showWordPopup(fallbackData, event); } } // ===== Verse-exact original word (STEP Bible interlinear, CC BY 4.0 Tyndale House) ===== // Replaces the old position-ratio guessing: every word of every verse is tagged with its exact // Hebrew/Greek word, Strong's number, lemma and grammar. Card fields are individually // customizable in Settings > Word Popup Visibility (separate OT/Hebrew and NT/Greek groups). const __ilVerseCache = new Map(); async function fetchInterlinearVerse(book, chapter, verse) { const key = book + '|' + chapter + '|' + verse; if (__ilVerseCache.has(key)) return __ilVerseCache.get(key); try { const r = await fetch(`/api/interlinear?book=${encodeURIComponent(book)}&chapter=${encodeURIComponent(chapter)}&verse=${encodeURIComponent(verse)}`, { cache: 'no-store' }); const j = r.ok ? await r.json() : null; const out = (j && j.ok) ? j : null; __ilVerseCache.set(key, out); if (__ilVerseCache.size > 300) __ilVerseCache.delete(__ilVerseCache.keys().next().value); return out; } catch (_) { return null; } } function __ilNorm(s) { return String(s || '').toLowerCase().replace(/[^a-z']/g, ''); } /* * Reduce an English word to a comparable stem so a tapped word matches the interlinear gloss even * when the two use different word forms. * * This is why "commendation" in Hebrews 11:2 opened the WRONG Greek word: the gloss on * ἐμαρτυρήθησαν is "were commended", and the old rule only chopped two letters off a long word * ("commendati"), which can never meet "commended". With no match the code fell back to guessing by * position in the verse, landing on the last token — πρεσβύτεροι, "the elders". Both words now * reduce to "commend" and land on the right word. * * Deliberately a light suffix stripper, not a full stemmer: one suffix, and never below four * characters, so it cannot collapse short unrelated words into each other. */ const __IL_SUFFIXES = [ ['ations', ''], ['ation', ''], ['ities', 'ity'], ['ements', ''], ['ement', ''], ['ments', ''], ['ment', ''], ['nesses', ''], ['ness', ''], ['ances', ''], ['ance', ''], ['ences', ''], ['ence', ''], ['tions', ''], ['sions', ''], ['tion', ''], ['sion', ''], ['ings', ''], ['ing', ''], ['edly', ''], ['ies', 'y'], ['ers', ''], ['er', ''], ['est', ''], ['ed', ''], ['es', ''], ['ly', ''], ['s', ''] ]; function __ilStem(s) { let w = __ilNorm(s); if (!w || w.length < 5) return w; // A trailing -s is only a plural: never strip it off -ss/-us/-is/-as endings, or "righteous" // would become "righteou" and stop matching "righteousness". const plainS = /(ss|us|is|as)$/.test(w); for (const [suf, rep] of __IL_SUFFIXES) { if (suf === 's' && plainS) continue; if (w.endsWith(suf) && (w.length - suf.length + rep.length) >= 4) { return w.slice(0, w.length - suf.length) + rep; } } return w; } // Match the tapped English word to the verse's tagged words via their English glosses. // Same-gloss duplicates ("son ... son") are disambiguated by position within the verse. function matchInterlinearWord(englishWord, wordSpan, words) { try { const target = __ilNorm(englishWord); if (!target || !Array.isArray(words) || !words.length) return null; const stem = target.length >= 6 ? target.slice(0, Math.max(4, target.length - 2)) : target; const tStem = __ilStem(target); const candidates = []; words.forEach((w, idx) => { const glossTokens = String(w.g || '').toLowerCase().replace(/[\[\]<>().,;:!?"“”]/g, ' ').split(/\s+/).map(__ilNorm).filter(Boolean); const lemmaTokens = String(w.lg || '').toLowerCase().replace(/[\[\]<>().,;:!?"“”»@]/g, ' ').split(/\s+/).map(__ilNorm).filter(Boolean); let score = 0; if (glossTokens.includes(target)) score = 3; // Stem match on the gloss ranks just under an exact hit: "commendation" -> "commend" meets // the gloss "were commended", which plain prefix comparison could never do. else if (tStem && glossTokens.some(t => __ilStem(t) === tStem)) score = 2.5; else if (glossTokens.some(t => t.startsWith(stem) || target.startsWith(t) && t.length >= 4)) score = 2; else if (lemmaTokens.includes(target)) score = 2; else if (tStem && lemmaTokens.some(t => __ilStem(t) === tStem)) score = 1.5; else if (lemmaTokens.some(t => t.startsWith(stem))) score = 1; if (score > 0) candidates.push({ idx, score, w }); }); if (!candidates.length) return null; const best = Math.max(...candidates.map(c => c.score)); const top = candidates.filter(c => c.score === best); if (top.length === 1) return top[0].w; // Disambiguate duplicates by relative position of the tapped word within the verse. let ratio = 0.5; try { const verseEl = wordSpan && wordSpan.closest ? wordSpan.closest('.verse') : null; const spans = verseEl ? Array.from(verseEl.querySelectorAll('.clickable-word')) : []; const i = spans.indexOf(wordSpan); if (i >= 0 && spans.length > 1) ratio = i / (spans.length - 1); } catch (_) {} const wantIdx = ratio * (words.length - 1); top.sort((a, b) => Math.abs(a.idx - wantIdx) - Math.abs(b.idx - wantIdx)); return top[0].w; } catch (_) { return null; } } // Plain-English grammar from the scholar codes. function decodeGreekMorph(m) { try { const code = String(m || '').trim(); if (!code) return ''; const POS = { V: 'Verb', N: 'Noun', A: 'Adjective', T: 'Article', P: 'Pronoun', R: 'Relative pronoun', D: 'Demonstrative pronoun', F: 'Reflexive pronoun', S: 'Possessive pronoun', X: 'Indefinite pronoun', I: 'Interrogative pronoun', K: 'Correlative pronoun', Q: 'Correlative/interrogative', C: 'Reciprocal pronoun', ADV: 'Adverb', CONJ: 'Conjunction', PREP: 'Preposition', PRT: 'Particle', INJ: 'Interjection', COND: 'Conditional', HEB: 'Hebrew word', ARAM: 'Aramaic word' }; const CASE = { N: 'nominative', G: 'genitive', D: 'dative', A: 'accusative', V: 'vocative' }; const NUM = { S: 'singular', P: 'plural' }; const GEN = { M: 'masculine', F: 'feminine', N: 'neuter' }; const TENSE = { P: 'present', I: 'imperfect', F: 'future', A: 'aorist', R: 'perfect', L: 'pluperfect', '2A': 'second aorist', '2R': 'second perfect', '2F': 'second future' }; const VOICE = { A: 'active', M: 'middle', P: 'passive', E: 'middle/passive', D: 'middle (deponent)', O: 'passive (deponent)', N: 'middle or passive (deponent)' }; const MOOD = { I: 'indicative', S: 'subjunctive', O: 'optative', M: 'imperative', N: 'infinitive', P: 'participle' }; const parts = code.split('-'); const pos = parts[0]; if (POS[pos] && !parts[1]) return POS[pos]; if (pos === 'V' && parts[1]) { let t = parts[1]; // '2AAI' = second aorist active indicative; 'AAI' = aorist active indicative const two = t[0] === '2'; let tense = TENSE[two ? t.slice(0, 2) : t[0]] || ''; let rest = two ? t.slice(2) : t.slice(1); const voice = VOICE[rest[0]] || ''; const mood = MOOD[rest[1]] || ''; let tail = ''; if (parts[2]) { const p2 = parts[2]; if (/^[123]/.test(p2)) tail = `, ${p2[0]}${p2[0] === '1' ? 'st' : p2[0] === '2' ? 'nd' : 'rd'} person ${NUM[p2[1]] || ''}`; else if (p2.length === 3) tail = `, ${CASE[p2[0]] || ''} ${NUM[p2[1]] || ''} ${GEN[p2[2]] || ''}`; } return `Verb — ${[tense, voice, mood].filter(Boolean).join(' ')}${tail}`.trim(); } if (POS[pos] && parts[1] && parts[1].length >= 3) { const p1 = parts[1]; if (/^[123]/.test(p1)) return `${POS[pos]} — ${p1[0]}${p1[0] === '1' ? 'st' : p1[0] === '2' ? 'nd' : 'rd'} person ${CASE[p1[1]] || ''} ${NUM[p1[2]] || ''}`.trim(); return `${POS[pos]} — ${CASE[p1[0]] || ''} ${NUM[p1[1]] || ''} ${GEN[p1[2]] || ''}`.replace(/\s+/g, ' ').trim(); } return POS[pos] || ''; } catch (_) { return ''; } } function decodeHebrewMorph(m) { try { let code = String(m || '').trim(); if (!code) return ''; if (/^[HA]/.test(code)) code = code.slice(1); // language prefix (H=Hebrew, A=Aramaic) const morphemes = code.split('/').filter(Boolean); // Pick the "main" morpheme: first verb, else first noun, else the last one. let main = morphemes.find(x => x[0] === 'V') || morphemes.find(x => x[0] === 'N') || morphemes[morphemes.length - 1] || ''; const STEM = { q: 'qal', N: 'niphal', p: 'piel', P: 'pual', h: 'hiphil', H: 'hophal', t: 'hithpael' }; const FORM = { p: 'perfect', q: 'sequential perfect', i: 'imperfect', w: 'sequential imperfect (wayyiqtol)', h: 'cohortative', j: 'jussive', v: 'imperative', r: 'participle (active)', s: 'participle (passive)', a: 'infinitive absolute', c: 'infinitive construct' }; const GEN = { m: 'masculine', f: 'feminine', c: 'common', b: 'both' }; const NUM = { s: 'singular', p: 'plural', d: 'dual' }; const STATE = { a: 'absolute', c: 'construct', d: 'determined' }; const t = main[0]; if (t === 'V') { const stem = STEM[main[1]] || ''; const form = FORM[main[2]] || ''; let tail = ''; const rest = main.slice(3); if (/^[123]/.test(rest)) tail = `, ${rest[0]}${rest[0] === '1' ? 'st' : rest[0] === '2' ? 'nd' : 'rd'} person ${GEN[rest[1]] || ''} ${NUM[rest[2]] || ''}`; else if (rest) tail = `, ${GEN[rest[0]] || ''} ${NUM[rest[1]] || ''} ${STATE[rest[2]] || ''}`; return `Verb — ${[stem, form].filter(Boolean).join(' ')}${tail}`.replace(/\s+/g, ' ').trim(); } if (t === 'N') { const kind = main[1] === 'p' ? 'proper ' : ''; return `Noun — ${kind}${GEN[main[2]] || ''} ${NUM[main[3]] || ''} ${STATE[main[4]] || ''}`.replace(/\s+/g, ' ').trim(); } if (t === 'A') return `Adjective — ${GEN[main[2]] || ''} ${NUM[main[3]] || ''}`.replace(/\s+/g, ' ').trim(); if (t === 'P') return 'Pronoun'; if (t === 'R') return 'Preposition'; if (t === 'C' || t === 'c') return 'Conjunction'; if (t === 'D') return 'Adverb'; if (t === 'T') return main[1] === 'd' ? 'Definite article ("the")' : (main[1] === 'o' ? 'Object marker' : 'Particle'); if (t === 'S') return 'Suffix'; return ''; } catch (_) { return ''; } } // Build the customizable card section. Every field has its own Settings toggle, // separate for Hebrew (ot_il_*) and Greek (nt_il_*). function buildInterlinearSectionHtml(il, isNT, attribution) { try { if (!il) return ''; const p = isNT ? 'nt_il' : 'ot_il'; if (!isPopupSectionVisible(p)) return ''; const rows = []; const langClass = isNT ? 'greek' : 'hebrew'; const dir = isNT ? 'ltr' : 'rtl'; // Header line: original word on the LEFT with the English word it renders on its RIGHT, so // it's always obvious which word in the verse the card is about. if (isPopupSectionVisible(p + '_word') && il.w) { const eng = il.en ? `${escapeHtml(il.en)}` : ''; rows.push(`
${escapeHtml(il.w)}${eng}
`); } if (isPopupSectionVisible(p + '_translit') && il.tr) { rows.push(`
${escapeHtml(il.tr)}
`); } if (isPopupSectionVisible(p + '_gloss') && il.g) { rows.push(`
Here: ${escapeHtml(il.g)}
`); } if (isPopupSectionVisible(p + '_definition') && il.def) { rows.push(`
Definition: ${escapeHtml(il.def).replace(/\n/g, '
')}
`); } if (isPopupSectionVisible(p + '_origin') && il.origin) { rows.push(`
Origin: ${escapeHtml(il.origin)}
`); } if (isPopupSectionVisible(p + '_usage') && il.usage && il.usage.n) { const tr = (il.usage.g || []).slice(0, 3).map(([w, c]) => `“${escapeHtml(w)}” (${c}×)`).join(', '); rows.push(`
Used: ${il.usage.n}× in Scripture${tr ? ` — as ${tr}` : ''}
`); } if (isPopupSectionVisible(p + '_lemma') && il.l) { const lg = il.lg ? ` — ${escapeHtml(String(il.lg).split('»')[0].trim())}` : ''; rows.push(`
Root: ${escapeHtml(il.l)}${lg}
`); } const plain = isNT ? decodeGreekMorph(il.m) : decodeHebrewMorph(il.m); if (isPopupSectionVisible(p + '_grammar') && plain) { rows.push(`
Grammar: ${escapeHtml(plain)}
`); } if (isPopupSectionVisible(p + '_morph_code') && il.m) { rows.push(`
Code: ${escapeHtml(il.m)}
`); } if (isPopupSectionVisible(p + '_strongs') && il.s) { rows.push(`
Strong's: ${escapeHtml(il.s)}
`); } if (!rows.length) return ''; if (il.kjv) rows.push('
(word appears in the KJV text)
'); // No label row (the popup TITLE shows Greek/Hebrew) and no attribution row on the card — // the CC BY attribution lives in Settings > Word Popup Cards instead. return `
${rows.join('')}
`; } catch (_) { return ''; } } // Strong's definition lookup for the card: the interlinear gives the EXACT Strong's number for // the word in this verse, so the definition comes from the local Strong's dictionaries directly. const __ilDicts = { g: null, h: null, tg: null, th: null, dg: null, loading: null }; async function __ensureIlDicts() { if (__ilDicts.g && __ilDicts.h && __ilDicts.tg && __ilDicts.th && __ilDicts.dg) return; if (__ilDicts.loading) return __ilDicts.loading; __ilDicts.loading = (async () => { try { const [g, h, tg, th, dg] = await Promise.all([ fetch('/api/dict/greek').then(r => r.ok ? r.json() : {}).catch(() => ({})), fetch('/api/dict/hebrew').then(r => r.ok ? r.json() : {}).catch(() => ({})), // Tyndale Brief lexicons (Abbott-Smith Greek / BDB-style Hebrew, CC BY) — the // preferred, modern definition source with real numbered senses. fetch('/dict/tbes-greek.json?v=2', { cache: 'force-cache' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), fetch('/dict/tbes-hebrew.json?v=2', { cache: 'force-cache' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), // Dodson's Greek Lexicon (CC0) — concise gloss-series definitions; this is the style the // pastor reads in Logos ("a messenger, generally a messenger from God, an angel..."). fetch('/dict/dodson-greek.json?v=1', { cache: 'force-cache' }).then(r => r.ok ? r.json() : {}).catch(() => ({})) ]); __ilDicts.g = g || {}; __ilDicts.h = h || {}; __ilDicts.tg = tg || {}; __ilDicts.th = th || {}; __ilDicts.dg = dg || {}; } catch (_) { __ilDicts.g = __ilDicts.g || {}; __ilDicts.h = __ilDicts.h || {}; __ilDicts.tg = __ilDicts.tg || {}; __ilDicts.th = __ilDicts.th || {}; __ilDicts.dg = __ilDicts.dg || {}; } })(); return __ilDicts.loading; } async function lookupStrongsDefinition(strongs, isNT) { try { if (!strongs) return ''; await __ensureIlDicts(); const dict = isNT ? __ilDicts.g : __ilDicts.h; if (!dict) return ''; const letter = strongs[0]; const num = parseInt(strongs.slice(1), 10); if (!num) return ''; // FIRST CHOICE for Greek: Dodson (CC0) — a concise gloss-series definition, the style the // pastor uses in Logos. Falls through to the fuller Tyndale/Abbott-Smith entry when a word // isn't in Dodson (it covers NT vocabulary), then to 1890 Strong's as a last resort. if (isNT && __ilDicts.dg) { const de = __ilDicts.dg[letter + String(num).padStart(4, '0')] || __ilDicts.dg[letter + num]; if (de && (de.d || de.b)) return de.d || de.b; } // Next: Tyndale Brief lexicon (Abbott-Smith / BDB senses). const tbes = isNT ? __ilDicts.tg : __ilDicts.th; const te = tbes && (tbes[letter + String(num).padStart(4, '0')] || tbes[letter + num]); if (te && te.d) { let d = te.d; if (d.length > 520) { d = d.slice(0, 520); const nl = d.lastIndexOf('\n'); d = (nl > 200 ? d.slice(0, nl) : d.replace(/\s+\S*$/, '')) + ' …'; } return d; } // Dictionaries key both padded and unpadded forms — try both. const entry = dict[letter + num] || dict[strongs] || dict[letter + String(num).padStart(4, '0')]; if (!entry) return ''; let def = entry.strongs_def || entry.definition || entry.strongsDefinition || entry.kjv_def || entry.kjvDefinition || ''; def = String(def).replace(/^[\s:;,-]+/, '').trim(); // Some raw entries begin with a spill-over of the derivation ("compare G34 (...) (to bring // tidings); a messenger; ...") — drop leading derivation-ish chunks so the card shows the // actual definition ("a messenger; especially an 'angel'..."). const chunks = def.split(';').map(s => s.trim()).filter(Boolean); while (chunks.length > 1 && /(compare|derived|probably|akin|see [GH]\d|[GH]\d{2,4})/i.test(chunks[0])) chunks.shift(); def = chunks.join('; '); if (def.length > 220) def = def.slice(0, 210).replace(/\s+\S*$/, '') + '…'; return def; } catch (_) { return ''; } } // Usage statistics per Strong's number (computed offline from the tagged Bible): // { n: total occurrences, g: [["angel",168],["messenger",7]] } — powers the Enhanced-Strong's-style // "175 occurrences; translated as ..." line the pastor sees in Logos. let __ilStats = null, __ilStatsLoading = null; async function __ensureIlStats() { if (__ilStats) return __ilStats; if (__ilStatsLoading) return __ilStatsLoading; __ilStatsLoading = fetch('/interlinear/_strongs-stats.json', { cache: 'force-cache' }) .then(r => r.ok ? r.json() : {}) .then(j => { __ilStats = j || {}; return __ilStats; }) .catch(() => { __ilStats = {}; return __ilStats; }); return __ilStatsLoading; } async function lookupStrongsUsage(strongs) { try { if (!strongs) return null; const stats = await __ensureIlStats(); const m = String(strongs).match(/([GH])0*(\d{1,4})/); if (!m) return null; return stats[m[1] + m[2].padStart(4, '0')] || stats[m[1] + m[2]] || null; } catch (_) { return null; } } // Word ORIGIN (pastor-requested): the roots a word/name is built from, with their meanings — // e.g. Laodicea = λαός (people) + δίκη (judgment). Comes from the Strong's "derivation" field: // component Strong's numbers are resolved to their lemma + short gloss; Hebrew derivations often // embed the name's meaning directly ("Israel — he will rule as God"), which is kept too. async function lookupStrongsOrigin(strongs, isNT) { try { if (!strongs) return ''; await __ensureIlDicts(); const dict = isNT ? __ilDicts.g : __ilDicts.h; if (!dict) return ''; const key = (s) => { const m = String(s || '').match(/([GH])0*(\d{1,4})/); return m ? (dict[m[1] + m[2]] || dict[m[1] + m[2].padStart(4, '0')]) : null; }; const entry = key(strongs); if (!entry || !entry.derivation) return ''; const der = String(entry.derivation); // Resolve referenced component roots. const refs = (der.match(/[GH]\d{1,4}/g) || []).slice(0, 3); const parts = []; // "from " with no Strong's number (e.g. "from ἀγγέλλω [...]") — // keep the literal root word as the first component. const litRoot = der.match(/from\s+([Ͱ-Ͽἀ-῿֐-׿][^\s\[\](),;]*)/); if (litRoot) parts.push(litRoot[1]); const tbesDict = isNT ? __ilDicts.tg : __ilDicts.th; const tbesGloss = (ref) => { try { const m = String(ref || '').match(/([GH])0*(\d{1,4})/); if (!m || !tbesDict) return ''; const e2 = tbesDict[m[1] + m[2].padStart(4, '0')] || tbesDict[m[1] + m[2]]; // Tyndale's brief gloss is usually the cleanest one-word meaning ("head", not KJV's "band"). if (!e2 || !e2.g) return ''; let gl = String(e2.g).replace(/\([^)]*\)/g, ' ').split(/[:;,]/)[0].replace(/\s+/g, ' ').trim(); // Reject proper-name glosses ("El", "Jah") — the KJV wordlist gives the common noun instead. if (/^[A-Z]/.test(gl) && gl.split(' ').length <= 2) return ''; return gl; } catch (_) { return ''; } }; for (const r of refs) { const e = key(r); if (!e) continue; let gloss = tbesGloss(r) || String(e.kjv_def || e.strongs_def || '') .replace(/\(([a-z-]+)\)(?=[a-z])/gi, '$1') // "(shew-)bread" -> "shew-bread" (paren glued to the word) .replace(/\s*\([^)]*\)/g, '') // "God (god)" -> "God" (standalone aside) .split(/[;,]/)[0] .replace(/\s*-\s+/g, '-') .replace(/\s+/g, ' ') .replace(/^[\s:.-]+|[\s:.-]+$/g, '') .trim(); if (gloss.length < 3) gloss = String(e.strongs_def || '').split(/[;,]/)[0].replace(/[()]/g, ' ').replace(/\s+/g, ' ').trim(); // "be"-style fragments → use the fuller definition if (/^(properly|probably|apparently|perhaps|literally|figuratively|especially)$/i.test(gloss)) { const next = String(e.strongs_def || '').split(/[;,]/)[1]; if (next) gloss = next.replace(/[()]/g, ' ').replace(/\s+/g, ' ').trim(); } if (gloss.length > 28) gloss = gloss.slice(0, 26).replace(/\s+\S*$/, '') + '…'; const lemma = (e.lemma || '').trim(); if (lemma && !parts.some(p => p.startsWith(lemma))) parts.push(gloss ? `${lemma} (${gloss})` : lemma); } // Embedded meaning clause (esp. Hebrew names): the plain-English phrase after the refs, // e.g. "from H8280 and H410; he will rule as God;" -> "he will rule as God". let embedded = ''; const clauses = der.split(';').map(s => s.trim()).filter(Boolean); for (const c of clauses) { if (/[GH]\d{1,4}/.test(c)) continue; // still the refs part if (/^(a |an )?primary\b|^apparently|^perhaps|^probably|^from the|^of uncertain|^akin to|^compare\b/i.test(c)) continue; // boilerplate if (/^[a-z].{3,}/i.test(c) && /[a-z]/i.test(c)) { embedded = c.replace(/[.;]+$/, '').trim(); break; } } // Fallback: a trailing parenthetical meaning, e.g. "from ἀγγέλλω [...] (to bring tidings);" if (!embedded) { const tail = der.match(/\(([a-z][^()]{3,60})\)[;.\s]*$/i); if (tail) embedded = tail[1].trim(); } if (!parts.length && !embedded) return ''; let out = parts.join(' + '); if (embedded) out = out ? `${out} — “${embedded}”` : `“${embedded}”`; if (out.length > 180) out = out.slice(0, 170).replace(/\s+\S*$/, '') + '…'; return out; } catch (_) { return ''; } } // Debug/diagnostic handle for the interlinear pipeline (also used by verification tooling) try { window.__interlinear = { fetchVerse: fetchInterlinearVerse, match: matchInterlinearWord, card: buildInterlinearSectionHtml, greekMorph: decodeGreekMorph, hebrewMorph: decodeHebrewMorph, strongsDef: lookupStrongsDefinition, strongsOrigin: lookupStrongsOrigin }; } catch (_) {} // Get popup visibility settings from localStorage function getPopupVisibilitySettings() { try { const saved = localStorage.getItem('popupVisibility'); if (saved) { return JSON.parse(saved); } } catch (e) { console.warn('Failed to load popup visibility settings:', e); } // Default: all sections visible return { title: true, // OT sections ot_wlca: true, ot_bdbt: true, ot_original: true, ot_transliteration: true, ot_pronunciation: true, ot_short_definition: true, ot_phonetic: true, ot_bdb_definition: true, // NT sections nt_ntgt: true, nt_strongs_greek: true, nt_original: true, nt_transliteration: true, nt_strongs_number: true, nt_morphology: true, nt_interlinear: true, nt_kjv_gloss: true, nt_derivation: true, nt_strongs_definition: true, divider: true }; } // Check if a popup section should be visible // These start OFF (pastor's verdict: "just need the definition" — the default card is word + // pronunciation + Definition). Every one can still be turned on in Settings like any section. const POPUP_DEFAULT_OFF = new Set([ 'ot_il_gloss', 'nt_il_gloss', 'ot_bdbt', 'nt_strongs_greek', 'ot_il_lemma', 'nt_il_lemma', 'ot_il_grammar', 'nt_il_grammar', 'ot_il_morph_code', 'nt_il_morph_code', 'ot_il_strongs', 'nt_il_strongs', 'ot_wlca', 'nt_ntgt' ]); // ON by default (pastor's asks): Definition, Origin, and Used ("175× — angel 168, messenger 7"). const POPUP_DEFAULT_ON = ['ot_il_usage', 'nt_il_usage', 'ot_il_definition', 'nt_il_definition', 'ot_il_origin', 'nt_il_origin']; // One-time migration per revision: reconcile previously-saved settings with the current card spec. // Anything can still be toggled afterwards in Settings. try { if (!localStorage.getItem('popupCardSpecV2')) { const savedVis = JSON.parse(localStorage.getItem('popupVisibility') || 'null'); if (savedVis && typeof savedVis === 'object') { POPUP_DEFAULT_OFF.forEach(k => { savedVis[k] = false; }); POPUP_DEFAULT_ON.forEach(k => { savedVis[k] = true; }); localStorage.setItem('popupVisibility', JSON.stringify(savedVis)); } localStorage.setItem('popupCardSpecV2', '1'); } } catch (_) {} function isPopupSectionVisible(sectionKey) { const settings = getPopupVisibilitySettings(); if (settings[sectionKey] === undefined && POPUP_DEFAULT_OFF.has(sectionKey)) return false; return settings[sectionKey] !== false; // Default to true if not set } /* * Place a word-definition card: horizontally CENTRED on screen, sitting just under the LINE that * holds the tapped word. * * This must run AFTER the card is in the DOM — the old code guessed the size before layout and then * clamped with a hard-coded 760px, so the 980px interlinear card hung ~220px off the right edge. * offsetWidth/offsetHeight are used rather than getBoundingClientRect() because the card carries a * CSS scale transform while animating in, and a rect would report the scaled (wrong) size. */ function positionWordPopup(popup, wordSpan, readingArea, stagger) { try { const areaRect = readingArea.getBoundingClientRect(); const viewW = readingArea.clientWidth; const viewH = readingArea.clientHeight; const scrollTop = readingArea.scrollTop || 0; const scrollLeft = readingArea.scrollLeft || 0; // Anchor to the LINE BOX containing the word, not the word's own box: a wrapped .verse span // produces one client rect per line, so pick the fragment holding the word's midpoint. const wordRect = wordSpan.getBoundingClientRect(); const lineHost = wordSpan.closest('.verse') || wordSpan.parentElement; let lineBottom = wordRect.bottom; try { const mid = wordRect.top + wordRect.height / 2; const rects = lineHost ? lineHost.getClientRects() : []; for (let i = 0; i < rects.length; i++) { const r = rects[i]; if (mid >= r.top && mid <= r.bottom) { lineBottom = Math.max(r.bottom, wordRect.bottom); break; } } } catch (_) {} /* * Size the card to the room beside the tapped LINE, instead of a flat 75% of the screen. * With the flat cap a full-height card could only fit below a word in the top ~23% of the screen, * and only flip above one in the bottom ~24%; for any word in the middle both branches failed and the * on-screen clamp below pinned the card at viewH - 12 - ph — a constant y, the same spot no matter * which word was tapped (measured 224px below the line). The card's content pane scrolls internally, * so a shorter card hides nothing. */ const lineInView = Math.round(lineBottom - areaRect.top); const roomBelow = viewH - lineInView - 24; const roomAbove = Math.round(wordRect.top - areaRect.top) - 24; const MIN_CARD = 260; // below this a card is not worth reading const placeAbove = roomBelow < MIN_CARD && roomAbove > roomBelow; const room = Math.max(MIN_CARD, placeAbove ? roomAbove : roomBelow); const cap = Math.round(Math.min(viewH * 0.75, room)); popup.style.maxHeight = cap + 'px'; // Only override the design width if the card genuinely cannot fit — otherwise we would clobber // the 980px interlinear layout from CSS. const natural = popup.offsetWidth; const pw = Math.min(natural, viewW - 32); if (pw < natural) popup.style.width = pw + 'px'; /* * Measure against the cap, not the raw offsetHeight. The card's CSS carries `transition: all 0.3s` * (styles.css:3319) and `max-height: 75vh` (styles.css:3325), so the inline cap set two lines up * ANIMATES in — for the whole 300ms offsetHeight still reports the uncapped content height. Placing * with that number made the on-screen clamp below fire against a card ~150px taller than the one the * viewer ends up with, which lifted the card ABOVE the tapped line and covered the word (measured: * card 481px tall at placement, 332px once settled, sitting 137px above the line instead of 12 below). * Clamping ph to the cap also makes later content growth harmless: the room reserved above is what * the card can ever occupy, so the anchor below never has to move again. */ const ph = Math.min(popup.offsetHeight, cap); const leftInContent = scrollLeft + Math.max(16, Math.round((viewW - pw) / 2)); // Anchored to the line, on whichever side was chosen when the card was sized above. let topInContent = placeAbove ? Math.round(wordRect.top - areaRect.top) + scrollTop - ph - 12 : lineInView + scrollTop + 12 + (stagger || 0); const bandTop = scrollTop + 12; const bandBottom = scrollTop + viewH - 12; // Final guarantee: the card is ON SCREEN. Needed because the anchor word is not always in view // (a restored card, or a word scrolled past), and "flip above" an off-screen word is still // off-screen. Being pulled over the text beats being invisible on stage. topInContent = Math.max(bandTop, Math.min(topInContent, bandBottom - ph)); popup.style.left = leftInContent + 'px'; popup.style.top = topInContent + 'px'; } catch (e) { console.warn('[POPUP] positioning failed, leaving default placement:', e && e.message); } } // Create and show word info popup function showWordPopup(wordInfo, event) { const popupKey = wordInfo && wordInfo.popupKey ? String(wordInfo.popupKey) : String(wordInfo?.word || ''); let isNTPopup = !!wordInfo?.isNT; try { const greekDetected = hasGreekText(wordInfo?.sourceHtml || wordInfo?.wlcaHtml); if (!wordInfo?.isNT && !greekDetected) { const popupBook = getPopupBookName(wordInfo, event); const popupBookNum = getBookNumberForName(popupBook); isNTPopup = !!popupBookNum && popupBookNum >= 40; } else if (greekDetected) { isNTPopup = true; } if (isNTPopup) { wordInfo.sourceLabel = 'NTGT'; wordInfo.sourceLang = 'el'; wordInfo.sourceDir = 'ltr'; wordInfo.sourceClass = 'greek'; wordInfo.dictionaryLabel = "Strong's Greek"; wordInfo.dictionaryClass = 'greek bdbt'; if (wordInfo.dictionaryHtml) { wordInfo.dictionaryHtml = wordInfo.dictionaryHtml .replace(/BDBT/gi, "Strong's Greek") .replace(/No Greek dictionary entry found/gi, 'No Strong\'s Greek entry found'); } if (!wordInfo.dictionaryHtml) { wordInfo.dictionaryHtml = '
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 = `
${showTitle ? `

${escapeHtml(title)}

` : '

'}
${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 = `

${reference}

${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); };