/** * Reader.js - Client-side interactivity for novel viewer * Handles theme toggle, font size, sidebar, and keyboard navigation */ (function() { 'use strict'; // DOM Elements const html = document.documentElement; const themeToggle = document.getElementById('theme-toggle'); const sidebarToggle = document.getElementById('sidebar-toggle'); const sidebar = document.getElementById('sidebar'); const fontBtns = document.querySelectorAll('.font-btn'); const hljsLight = document.getElementById('hljs-light'); const hljsDark = document.getElementById('hljs-dark'); const header = document.querySelector('.reader-header'); const progressBar = document.getElementById('progress-bar'); const progressFill = progressBar?.querySelector('.progress-bar-fill'); const shortcutsToast = document.getElementById('shortcuts-toast'); const shortcutsOverlay = document.getElementById('shortcuts-overlay'); // Storage keys (shared with kanban dashboard for theme persistence) const THEME_KEY = 'theme'; const FONT_KEY = 'novel-viewer-font'; const SIDEBAR_KEY = 'novel-viewer-sidebar'; const TOAST_SHOWN_KEY = 'reader:shortcuts-toast-shown'; // Scroll state let lastScrollY = 0; let scrollTicking = false; // Initialize theme function initTheme() { const stored = localStorage.getItem(THEME_KEY); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const theme = stored || (prefersDark ? 'dark' : 'light'); setTheme(theme); } // Set theme function setTheme(theme, skipMermaid = false) { html.dataset.theme = theme; localStorage.setItem(THEME_KEY, theme); // Switch highlight.js theme if (hljsLight && hljsDark) { hljsLight.disabled = theme === 'dark'; hljsDark.disabled = theme === 'light'; } // Re-render mermaid diagrams with new theme (skip on initial load) if (!skipMermaid && window.mermaidModule) { updateMermaidTheme(); } } // Toggle theme function toggleTheme() { const current = html.dataset.theme || 'light'; const next = current === 'light' ? 'dark' : 'light'; setTheme(next); } // Initialize font size function initFontSize() { const stored = localStorage.getItem(FONT_KEY) || 'M'; setFontSize(stored); } // Set font size function setFontSize(size) { html.dataset.fontSize = size; localStorage.setItem(FONT_KEY, size); // Update button states fontBtns.forEach(btn => { btn.classList.toggle('active', btn.dataset.size === size); }); } // Initialize sidebar function initSidebar() { const stored = localStorage.getItem(SIDEBAR_KEY); const isMobile = window.innerWidth <= 900; if (isMobile) { sidebar?.classList.add('hidden'); } else if (stored === 'hidden') { sidebar?.classList.add('hidden'); } } // Toggle sidebar function toggleSidebar() { const isHidden = sidebar?.classList.toggle('hidden'); localStorage.setItem(SIDEBAR_KEY, isHidden ? 'hidden' : 'visible'); } // Show shortcuts toast (first visit) function showToast() { if (!shortcutsToast) return; const hasShown = localStorage.getItem(TOAST_SHOWN_KEY); if (hasShown) return; // Show toast after short delay setTimeout(() => { shortcutsToast.classList.add('show'); // Auto-dismiss after 5 seconds setTimeout(() => { dismissToast(); }, 5000); }, 1000); } // Dismiss toast function dismissToast() { if (!shortcutsToast) return; shortcutsToast.classList.remove('show'); localStorage.setItem(TOAST_SHOWN_KEY, 'true'); } // Show shortcuts cheatsheet function showCheatsheet() { if (!shortcutsOverlay) return; shortcutsOverlay.removeAttribute('hidden'); shortcutsOverlay.setAttribute('aria-hidden', 'false'); // Focus trap const closeBtn = shortcutsOverlay.querySelector('.modal-close'); closeBtn?.focus(); } // Hide shortcuts cheatsheet function hideCheatsheet() { if (!shortcutsOverlay) return; shortcutsOverlay.setAttribute('hidden', ''); shortcutsOverlay.setAttribute('aria-hidden', 'true'); } // Initialize shortcuts function initShortcuts() { // Toast dismiss button const dismissBtn = shortcutsToast?.querySelector('.toast-dismiss'); dismissBtn?.addEventListener('click', dismissToast); // Cheatsheet close button const closeBtn = shortcutsOverlay?.querySelector('.modal-close'); closeBtn?.addEventListener('click', hideCheatsheet); // Backdrop click const backdrop = shortcutsOverlay?.querySelector('.shortcuts-backdrop'); backdrop?.addEventListener('click', hideCheatsheet); // Show toast on first visit showToast(); } // Keyboard navigation function handleKeydown(e) { // Skip if in input/textarea if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { return; } // Close cheatsheet on Escape if (e.key === 'Escape' && shortcutsOverlay && !shortcutsOverlay.hasAttribute('hidden')) { e.preventDefault(); hideCheatsheet(); return; } // Close bottom sheet on Escape const bottomSheet = document.getElementById('bottom-sheet'); if (e.key === 'Escape' && bottomSheet && bottomSheet.getAttribute('aria-hidden') === 'false') { e.preventDefault(); if (window.closeBottomSheet) { window.closeBottomSheet(); } return; } // Show cheatsheet on ? if (e.key === '?' && !e.shiftKey) { e.preventDefault(); showCheatsheet(); return; } const navPrev = document.querySelector('.nav-prev'); const navNext = document.querySelector('.nav-next'); switch (e.key) { case 'ArrowLeft': if (navPrev) { e.preventDefault(); window.location.href = navPrev.href; } break; case 'ArrowRight': if (navNext) { e.preventDefault(); window.location.href = navNext.href; } break; case 'Escape': if (window.innerWidth <= 900 && sidebar && !sidebar.classList.contains('hidden')) { toggleSidebar(); } break; case 't': case 'T': if (!e.ctrlKey && !e.metaKey) { toggleTheme(); } break; case 's': case 'S': if (!e.ctrlKey && !e.metaKey) { toggleSidebar(); } break; } } // Smooth scroll to anchor with sidebar active state update function handleAnchorClick(e) { const anchor = e.target.closest('a'); const href = anchor?.getAttribute('href'); if (href?.startsWith('#')) { e.preventDefault(); const targetId = href.slice(1); const target = document.getElementById(targetId); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); history.pushState(null, '', href); // Update sidebar active state updateSidebarActiveState(targetId); } } } // Update sidebar active state based on anchor function updateSidebarActiveState(anchorId) { const planNav = document.getElementById('plan-nav'); if (!planNav) return; // Remove active from all items planNav.querySelectorAll('.phase-item').forEach(item => { item.classList.remove('active'); }); // Add active to matching item const matchingItem = planNav.querySelector(`[data-anchor="${anchorId}"]`); if (matchingItem) { matchingItem.classList.add('active'); } } // Setup Intersection Observer for section tracking function setupSectionObserver() { const planNav = document.getElementById('plan-nav'); if (!planNav) return; // Get all anchors from sidebar const anchors = Array.from(planNav.querySelectorAll('[data-anchor]')) .map(item => item.dataset.anchor); if (anchors.length === 0) return; // Find corresponding elements in content const sections = anchors .map(id => document.getElementById(id)) .filter(el => el !== null); if (sections.length === 0) return; // Create observer const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { updateSidebarActiveState(entry.target.id); } }); }, { rootMargin: '-20% 0px -60% 0px', // Trigger when section is in upper portion of viewport threshold: 0 }); // Observe all sections sections.forEach(section => observer.observe(section)); } // Handle hash change (browser back/forward) function handleHashChange() { const hash = window.location.hash; if (hash) { const targetId = hash.slice(1); const target = document.getElementById(targetId); if (target) { updateSidebarActiveState(targetId); } } } // Throttle utility function throttle(func, wait) { let timeout; return function(...args) { if (!timeout) { timeout = setTimeout(() => { timeout = null; func.apply(this, args); }, wait); } }; } // Update progress bar based on scroll position function updateProgressBar() { if (!progressFill) return; const scrollTop = window.scrollY; const docHeight = document.documentElement.scrollHeight - window.innerHeight; const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0; progressFill.style.width = `${Math.min(progress, 100)}%`; } // Handle scroll for progress bar and fixed header shadow function handleScroll() { if (!header) return; const currentScrollY = window.scrollY; // Add fixed shadow when scrolled past header if (currentScrollY > 60) { header.classList.add('is-fixed'); } else { header.classList.remove('is-fixed'); } lastScrollY = currentScrollY; // Update progress bar using requestAnimationFrame if (!scrollTicking) { window.requestAnimationFrame(() => { updateProgressBar(); scrollTicking = false; }); scrollTicking = true; } } // Initialize Mermaid diagrams async function initMermaid() { // Wait for mermaid module to load (imported in template.html) let attempts = 0; while (!window.mermaidModule && attempts < 50) { await new Promise(r => setTimeout(r, 100)); attempts++; } if (!window.mermaidModule) { console.warn('Mermaid module not loaded'); return; } const mermaid = window.mermaidModule; const isDark = html.dataset.theme === 'dark'; // Initialize mermaid with theme mermaid.initialize({ startOnLoad: false, theme: isDark ? 'dark' : 'default', securityLevel: 'loose', fontFamily: 'Inter, sans-serif' }); // Find unprocessed mermaid elements (both pre and div) const diagrams = document.querySelectorAll('.mermaid:not([data-processed="true"])'); if (diagrams.length === 0) { return; // Nothing to render } // Store original source before mermaid replaces content (for theme switching) diagrams.forEach(el => { if (!el.dataset.mermaidSource) { el.dataset.mermaidSource = el.textContent; } }); // Use mermaid.run() - the preferred API for v10+ try { await mermaid.run({ nodes: diagrams, suppressErrors: false }); } catch (err) { console.error('Mermaid run error:', err); // Show errors inline for diagrams that failed diagrams.forEach(el => { if (!el.querySelector('svg') && !el.hasAttribute('data-processed')) { const code = el.dataset.mermaidSource || el.textContent; el.innerHTML = `
Mermaid Error:
${err.message || err}
Source
${code}
`; el.classList.add('mermaid-error-container'); } }); } } // Re-render mermaid on theme change async function updateMermaidTheme() { if (!window.mermaidModule) return; const isDark = html.dataset.theme === 'dark'; // Re-initialize with new theme window.mermaidModule.initialize({ startOnLoad: false, theme: isDark ? 'dark' : 'default', securityLevel: 'loose', fontFamily: 'Inter, sans-serif' }); // Restore original source and re-render const diagrams = document.querySelectorAll('.mermaid[data-processed="true"]'); diagrams.forEach(el => { const source = el.dataset.mermaidSource; if (source) { el.textContent = source; el.removeAttribute('data-processed'); } }); await initMermaid(); // Re-init expand buttons after re-render setTimeout(() => initMermaidExpand(), 100); } // Compute width and left offset for an expanded wrapper to fill .main-content // without using viewport units (immune to sidebar state changes) function applyExpandLayout(wrapper, isExpanded) { if (!isExpanded) { wrapper.style.width = ''; wrapper.style.left = ''; return; } const mainContent = document.querySelector('.main-content'); if (!mainContent) return; const mainRect = mainContent.getBoundingClientRect(); const mainPadding = parseFloat(getComputedStyle(mainContent).paddingLeft) || 0; const availableWidth = mainRect.width - mainPadding * 2; const wrapperRect = wrapper.getBoundingClientRect(); const offset = mainRect.left + mainPadding - wrapperRect.left; wrapper.style.width = availableWidth + 'px'; wrapper.style.left = offset + 'px'; } // Recalculate expanded wrappers on resize or sidebar toggle window.addEventListener('resize', () => { document.querySelectorAll('.mermaid-wrapper.expanded, .code-wrapper.expanded') .forEach(w => applyExpandLayout(w, true)); }); // Initialize Mermaid expand toggle buttons function initMermaidExpand() { // Find all rendered mermaid diagrams not already wrapped const diagrams = document.querySelectorAll('.mermaid[data-processed="true"]'); diagrams.forEach(diagram => { // Skip if already wrapped if (diagram.parentElement?.classList.contains('mermaid-wrapper')) { return; } // Create wrapper const wrapper = document.createElement('div'); wrapper.className = 'mermaid-wrapper'; // Create expand button const btn = document.createElement('button'); btn.className = 'mermaid-expand-btn'; btn.setAttribute('aria-label', 'Expand diagram to full width'); btn.innerHTML = ` `; // Toggle handler — expand to fill .main-content, re-render at new width btn.addEventListener('click', async () => { const isExpanded = wrapper.classList.toggle('expanded'); btn.setAttribute('aria-label', isExpanded ? 'Collapse diagram' : 'Expand diagram to full width' ); applyExpandLayout(wrapper, isExpanded); // Re-render mermaid at the new container width for crisp output const source = diagram.dataset.mermaidSource; if (source && window.mermaidModule) { diagram.textContent = source; diagram.removeAttribute('data-processed'); try { await window.mermaidModule.run({ nodes: [diagram], suppressErrors: false }); } catch (e) { console.error('Mermaid re-render on expand failed:', e); } } }); // Insert wrapper before diagram diagram.parentNode.insertBefore(wrapper, diagram); // Move diagram into wrapper wrapper.appendChild(diagram); // Add button to wrapper wrapper.appendChild(btn); }); } // Initialize code block expand toggle buttons (for wide ASCII art) function initCodeExpand() { // Find all pre elements that are not mermaid and not already wrapped const codeBlocks = document.querySelectorAll('pre:not(.mermaid)'); codeBlocks.forEach(pre => { // Skip if already wrapped or inside mermaid error if (pre.parentElement?.classList.contains('code-wrapper') || pre.parentElement?.classList.contains('mermaid-error')) { return; } // Only add expand button if content is wider than container // Check if scrollWidth > clientWidth (has horizontal overflow) if (pre.scrollWidth <= pre.clientWidth + 10) { return; // No overflow, no need for expand button } // Create wrapper const wrapper = document.createElement('div'); wrapper.className = 'code-wrapper'; // Create expand button const btn = document.createElement('button'); btn.className = 'code-expand-btn'; btn.setAttribute('aria-label', 'Expand code block to full width'); btn.innerHTML = ` `; // Toggle handler — expand to fill .main-content btn.addEventListener('click', () => { const isExpanded = wrapper.classList.toggle('expanded'); btn.setAttribute('aria-label', isExpanded ? 'Collapse code block' : 'Expand code block to full width' ); applyExpandLayout(wrapper, isExpanded); }); // Insert wrapper before pre pre.parentNode.insertBefore(wrapper, pre); // Move pre into wrapper wrapper.appendChild(pre); // Add button to wrapper wrapper.appendChild(btn); // Auto-expand overflowing code blocks to fill available width // (user can collapse back via the button) wrapper.classList.add('expanded'); btn.setAttribute('aria-label', 'Collapse code block'); applyExpandLayout(wrapper, true); }); } // Initialize // Initialize accordion function initAccordion() { const phaseHeaders = document.querySelectorAll('.phase-header'); if (phaseHeaders.length === 0) return; // Get plan identifier for localStorage key const planNav = document.getElementById('plan-nav'); if (!planNav) return; const planName = planNav.querySelector('.plan-title span:last-child')?.textContent || 'unknown'; const planId = planName.toLowerCase().replace(/[^a-z0-9]+/g, '-'); phaseHeaders.forEach(header => { const phaseGroup = header.closest('.phase-group'); if (!phaseGroup) return; const phaseId = phaseGroup.dataset.phaseId; const storageKey = `reader:accordion:${planId}:${phaseId}`; // Restore collapsed state from localStorage try { const collapsed = localStorage.getItem(storageKey); if (collapsed === 'true') { phaseGroup.classList.add('collapsed'); } } catch (e) { // Graceful fallback if localStorage unavailable } // Toggle handler const toggleAccordion = () => { const isCollapsed = phaseGroup.classList.toggle('collapsed'); // Persist state try { localStorage.setItem(storageKey, isCollapsed); } catch (e) { // Graceful fallback } }; // Click handler header.addEventListener('click', toggleAccordion); // Keyboard accessibility header.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleAccordion(); } }); }); } // Initialize mobile navigation (FAB + bottom sheet) function initMobileNav() { const fabMenu = document.getElementById('fab-menu'); const fabNext = document.querySelector('.nav-next-mobile'); const fabPrev = document.querySelector('.nav-prev-mobile'); const bottomSheet = document.getElementById('bottom-sheet'); const bottomSheetContent = document.getElementById('bottom-sheet-content'); const backdrop = bottomSheet?.querySelector('.bottom-sheet-backdrop'); const handle = bottomSheet?.querySelector('.bottom-sheet-handle'); const sidebar = document.getElementById('sidebar'); if (!fabMenu || !bottomSheet) return; // Set FAB hrefs from footer navigation const navNext = document.querySelector('.nav-next'); const navPrev = document.querySelector('.nav-prev'); if (navNext && fabNext) { fabNext.href = navNext.href; } if (navPrev && fabPrev) { fabPrev.href = navPrev.href; } // Clone sidebar content to bottom sheet if (sidebar && bottomSheetContent) { bottomSheetContent.innerHTML = sidebar.innerHTML; } // Open bottom sheet function openBottomSheet() { bottomSheet.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; } // Close bottom sheet function closeBottomSheet() { bottomSheet.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; } // FAB menu click handler fabMenu.addEventListener('click', openBottomSheet); // Backdrop click handler backdrop?.addEventListener('click', closeBottomSheet); // Swipe down gesture on handle let touchStartY = 0; let touchEndY = 0; handle?.addEventListener('touchstart', (e) => { touchStartY = e.touches[0].clientY; }, { passive: true }); handle?.addEventListener('touchmove', (e) => { touchEndY = e.touches[0].clientY; }, { passive: true }); handle?.addEventListener('touchend', () => { const swipeDistance = touchEndY - touchStartY; // Swipe down threshold: 50px if (swipeDistance > 50) { closeBottomSheet(); } touchStartY = 0; touchEndY = 0; }); // Close on resize to desktop let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (window.innerWidth > 768 && bottomSheet.getAttribute('aria-hidden') === 'false') { closeBottomSheet(); } }, 100); }); // Store close function for keyboard handler window.closeBottomSheet = closeBottomSheet; } // Sidebar resize functionality const SIDEBAR_WIDTH_KEY = 'novel-viewer-sidebar-width'; const resizeHandle = document.getElementById('sidebar-resize'); const mainContent = document.querySelector('.main-content'); function initSidebarResize() { if (!resizeHandle || !sidebar) return; // Restore saved width const savedWidth = localStorage.getItem(SIDEBAR_WIDTH_KEY); if (savedWidth) { const width = parseInt(savedWidth, 10); if (width >= 200 && width <= 480) { sidebar.style.width = width + 'px'; if (mainContent) mainContent.style.marginLeft = width + 'px'; } } let isDragging = false; let startX = 0; let startWidth = 0; resizeHandle.addEventListener('mousedown', (e) => { isDragging = true; startX = e.clientX; startWidth = sidebar.offsetWidth; resizeHandle.classList.add('dragging'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const delta = e.clientX - startX; const newWidth = Math.min(480, Math.max(200, startWidth + delta)); sidebar.style.width = newWidth + 'px'; if (mainContent) mainContent.style.marginLeft = newWidth + 'px'; }); document.addEventListener('mouseup', () => { if (!isDragging) return; isDragging = false; resizeHandle.classList.remove('dragging'); document.body.style.cursor = ''; document.body.style.userSelect = ''; localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebar.offsetWidth.toString()); }); } // Initialize function init() { initTheme(); initFontSize(); initSidebar(); initSidebarResize(); initAccordion(); initShortcuts(); initMobileNav(); initMermaid().then(() => { // Initialize expand buttons after mermaid renders setTimeout(() => initMermaidExpand(), 100); }); // Initialize code block expand buttons after fonts load // (font metrics affect scrollWidth used for overflow detection) if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => initCodeExpand()); } else { // Fallback for browsers without Font Loading API setTimeout(() => initCodeExpand(), 300); } // Event listeners themeToggle?.addEventListener('click', toggleTheme); sidebarToggle?.addEventListener('click', toggleSidebar); fontBtns.forEach(btn => { btn.addEventListener('click', () => setFontSize(btn.dataset.size)); }); document.addEventListener('keydown', handleKeydown); document.addEventListener('click', handleAnchorClick); // Handle hash change for sidebar active state window.addEventListener('hashchange', handleHashChange); // Setup section observer for auto-highlighting sidebar setupSectionObserver(); // Handle initial hash on page load if (window.location.hash) { handleHashChange(); } // Initialize scroll state and handlers lastScrollY = window.scrollY; updateProgressBar(); window.addEventListener('scroll', throttle(handleScroll, 100), { passive: true }); // Handle resize let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (window.innerWidth > 900) { sidebar?.classList.remove('visible'); } }, 100); }); // Listen for system theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (!localStorage.getItem(THEME_KEY)) { setTheme(e.matches ? 'dark' : 'light'); } }); } // Run when DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();