851 lines
26 KiB
JavaScript
851 lines
26 KiB
JavaScript
/**
|
|
* 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 = `<div class="mermaid-error">
|
|
<strong>Mermaid Error:</strong>
|
|
<pre>${err.message || err}</pre>
|
|
<details><summary>Source</summary><pre>${code}</pre></details>
|
|
</div>`;
|
|
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 = `
|
|
<span class="icon-expand">⤢</span>
|
|
<span class="icon-collapse">⤡</span>
|
|
`;
|
|
|
|
// 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 = `
|
|
<span class="icon-expand">⤢</span>
|
|
<span class="icon-collapse">⤡</span>
|
|
`;
|
|
|
|
// 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();
|
|
}
|
|
})();
|