// TNC FlipBook 3D — tnc-flipbook.js
// ES6 module. Import TNCFlipbook and instantiate with a container + config.
//
// Usage (standalone):
//   import { TNCFlipbook } from './src/tnc-flipbook.js';
//   new TNCFlipbook('#container', CONFIG, { dependencies, workerSrc, tts, colors, templatePath });
//
// Usage (WordPress / global):
//   window.THREE / window.pdfjsLib / window.initTranslations must be set before calling new TNCFlipbook()

// =============================================
// MODULE-SCOPED STATE
// All classes reference these variables directly (zero changes needed inside class bodies)
// =============================================
let CONFIG, TTS_CONFIG, COLOR_CONFIG;
let THREE, pdfjsLib, initTranslations, rtlLanguages, availableLanguages;
let _gltfLoaderPath = './three.js-master/examples/jsm/loaders/GLTFLoader.js';
let _orbitControlsPath = './three.js-master/examples/jsm/controls/OrbitControls.js';
let i18nStrings = {};
let i18nFallback = {};

// Deep merge utility (non-destructive, handles nested objects)
function _deepMerge(target, source) {
    if (!source || typeof source !== 'object') return target;
    const result = Object.assign({}, target);
    for (const key of Object.keys(source)) {
        if (source[key] !== null && typeof source[key] === 'object' && !Array.isArray(source[key])) {
            result[key] = _deepMerge(target[key] || {}, source[key]);
        } else if (source[key] !== undefined) {
            result[key] = source[key];
        }
    }
    return result;
}


// Lazy-loaded Three.js extras for 3D annotation viewing
let GLTFLoaderModule = null;
let OrbitControlsModule = null;
async function loadGLTFLoader() {
    if (!GLTFLoaderModule) {
        GLTFLoaderModule = await import(_gltfLoaderPath);
    }
    return GLTFLoaderModule;
}
async function loadOrbitControls() {
    if (!OrbitControlsModule) {
        OrbitControlsModule = await import(_orbitControlsPath);
    }
    return OrbitControlsModule;
}

// =============================================
// CONFIGURATION - Set your content source here
// =============================================
const DEFAULT_CONFIG = {
    // ===================
    // CONTENT SOURCE OPTIONS (use ONE of these):
    // ===================

    // Option 1: Single PDF file
    pdfSource: '',

    // Option 2: Multiple PDFs (concatenated into one flipbook)
    // If pdfSources is provided, pdfSource is ignored
    // pdfSources: [],

    // Option 3: Array of images (if provided, PDF options are ignored)
    // Supports: jpg, jpeg, png, gif, webp, svg
    // imageSources: [
    //     './zeeana/1.jpg',
    //     './zeeana/2.jpg',
    //     './zeeana/3.jpg',
    //     './zeeana/4.jpg',
    //     './zeeana/5.jpg',
    // ],
    // Or with section names:
    // imageSources: [
    //     { src: './images/cover.jpg', name: 'Cover' },
    //     { src: './images/page1.jpg', name: 'Chapter 1' },
    //     { src: './images/page2.jpg' },
    //     { src: './images/page3.jpg', name: 'Chapter 2' },
    // ],

    // =============================================
    // PAGE VIDEO EMBEDS
    // =============================================
    // Embed videos on specific pages. Videos overlay the page content.
    // Supports: mp4, webm, ogg (local files or URLs)
    //

    // Hotspots: clickable pins or invisible areas on pages
    // Each hotspot has an action: 'popup' (default), 'url', 'page', or 'media'
    // Hotspot format: { page, x, y, type: 'pin'|'area', action: 'popup'|'url'|'page'|'media', icon, color, ... }
    hotspots: [],

    // Page dimensions (will be auto-calculated from first page/image)
    pageWidth: 4,
    pageHeight: 5.5,
    pageDepth: 0.01,

    // Animation settings
    flipDuration: 1200,
    fastFlipDuration: 300,
    flipEffect: 'realistic', // 'realistic', 'hard', 'slide', 'fade'

    // Flip sound: 1-5 (file-based) or 'none' to disable
    flipSound: '1',

    // Render quality (higher = better quality, more memory)
    renderScale: 2,

    // Memory hotfix gates (set memoryOptimized: false to revert to pre-hotfix behavior)
    memoryOptimized: true,
    maxRenderPixels: 2048,   // Hard cap on PDF page canvas longest edge (px)
    bgLoadBatchSize: 2,      // Parallel pages rendered during background load

    // Toolbar layout: 'bottom-center', 'top-center', 'bottom-fullwidth', or 'top-fullwidth'
    toolbarLayout: 'bottom-center',

    // Flipbook style: 'default', 'hardcover', or 'spiral'
    flipbookStyle: 'default',

    // Reading direction: false = LTR, true = RTL
    isRTL: false,

    // =============================================
    // LANGUAGE / INTERNATIONALIZATION
    // =============================================
    // Language code for UI strings (e.g., 'en', 'es', 'fr', 'de', 'ar', 'zh', 'ja')
    // See translations.js for full list of available languages
    language: 'bn',

    // PDF link target: 'new' opens in new tab, 'same' opens in same window
    linkTarget: 'new',

    // Deep linking: update URL hash on page change (#page/N) and navigate on load
    deepLinking: true,

    // Render mode: 'auto' (3D if WebGL available, else 2D), '3d' (force 3D), '2d' (force 2D CSS flip)
    renderMode: 'auto',

    // =============================================
    // LOADING SCREEN
    // =============================================
    // Logo to display on loading screen (set to null or '' to hide)
    loadingLogo: './tnc-flipbook-icon-white.png',
    // Max width of the loading logo in pixels
    loadingLogoMaxWidth: 150,

    // =============================================
    // GOOGLE ANALYTICS 4
    // =============================================
    // Your GA4 Measurement ID (e.g., 'G-XXXXXXXXXX')
    // Set to null or '' to disable analytics

    // =============================================
    // CONTENT PROTECTION (DRM)
    // =============================================
    contentProtection: { hideSourcePaths: false },

    // =============================================
    // ACCESSIBILITY (WCAG 2.1 AA)
    // =============================================
    accessibility: {
        enabled: true,                    // Master switch for accessibility features
        skipLinks: true,                  // Add "Skip to flipbook" link
        keyboardShortcuts: true,          // Enhanced keyboard navigation (arrows, Home/End, /, ?)
        screenReaderAnnouncements: true,  // aria-live announcements for page changes, search, etc.
        focusIndicators: true,            // Visible focus rings for keyboard navigation
        reducedMotion: true,              // Respect prefers-reduced-motion OS setting
        highContrastMode: false,          // Optional enhanced contrast mode
        keyboardShortcutsHelp: true,      // Show keyboard shortcuts modal with ? key
    },

    // =============================================
    // TOOLBAR ITEMS VISIBILITY
    // =============================================
    // Set any to false to hide that toolbar item.
    // Omitting the toolbar object entirely shows everything (default).
    toolbar: {
        share: true,
        download: true,
        print: true,
        search: true,
        tilt: true,
        rotate: true,
        zoom: true,           // Hides zoom dropdown + zoom in/out buttons
        readingMode: true,
        readAloud: true,
        fullscreen: true,
        themeToggle: true,
        moreMenu: true,       // Hides the "..." menu button entirely
        pageIndicator: true,
        logo: true,
        close: true,          // Top-right close button
        // More menu items (only relevant if moreMenu is true)
        autoPlay: true,
        sound: true,
        singlePage: true,
        bookmarks: true,
        notes: true,
        thumbnails: true,
        viewModes: true,      // Hides all 3 view mode options (flipbook/vertical/horizontal)
        formViewer: true,
        flipbookStyles: true, // Hides all 3 style options (default/hardcover/spiral)
        rtl: true,
        // Navigation arrows
        navigation: true,    // Prev/Next arrows
        firstLast: true,     // First/Last page arrows
    },

    // =============================================
    // DEFAULT VIEW MODE
    // =============================================
    // 'flipbook'    — Standard 3D/2D page-flip (default)
    // 'single'      — Single page mode (one page at a time)
    // 'vertical'    — Vertical scroll viewer
    // 'horizontal'  — Horizontal scroll viewer
    defaultViewMode: 'flipbook',

    // Page pairing mode:
    // 'odd'  — Page 1 is standalone cover (right side), spreads are 2-3, 4-5, etc.
    // 'even' — Pages 1-2 are first spread, then 3-4, 5-6, etc. (no standalone cover)
    pageMode: 'odd',

    // =============================================
    // PAPER APPEARANCE SETTINGS
    // =============================================

    // Paper type: 'matte', 'glossy', 'textured', 'newsprint'
    // - matte: Flat, non-reflective paper (books, documents)
    // - glossy: Shiny, reflective surface (magazines, photos)
    // - textured: Slightly rough with subtle surface detail
    // - newsprint: Thin, slightly transparent paper feel
    paperType: 'glossy',

    // Paper color tint (hex color, affects page edges and back)
    // Use '#ffffff' for bright white, '#f5f5dc' for cream/off-white
    paperTint: '#f0e6c8',

    // =============================================
    // PAGE MATERIAL PROPERTIES
    // =============================================

    // Page shininess: 0 (completely matte) to 1 (mirror-like)
    pageShininess: 0.3,

    // Page roughness: 0 (smooth/glossy) to 1 (rough/diffuse)
    pageRoughness: 0.5,

    // Curl intensity: controls how dramatic the page bend is during flips
    // 0.3 = subtle, 1.0 = normal, 1.5 = very dramatic
    curlIntensity: 1.0,

    // =============================================
    // CAMERA & PERSPECTIVE SETTINGS
    // =============================================

    // Camera distance from book (affects zoom level)
    // Range: 6 (close up) to 20 (far away)
    cameraDistance: 8,

    // Camera height angle in degrees
    // Range: 0 (eye level) to 45 (looking down from above)
    cameraAngle: 2,

    // Camera field of view in degrees
    // Range: 30 (telephoto/flat) to 75 (wide angle/dramatic)
    cameraFOV: 50,

    // Book tilt angle in degrees (forward tilt)
    // Range: 0 (flat) to 25 (tilted toward viewer)
    bookTilt: 9,

    // =============================================
    // LIGHTING & SHADOW SETTINGS
    // =============================================

    // Ambient light intensity: 0 (dark) to 2 (bright)
    // Higher values = more even lighting, less contrast
    ambientLightIntensity: 1.2,

    // Main directional light intensity: 0 to 2
    // Creates highlights and shadows on pages
    mainLightIntensity: 0.8,

    // Main light position [x, y, z] - where light comes from
    // Default [0, 10, 10] = above and in front
    mainLightPosition: [0, 10, 10],

    // Enable shadows: true/false
    shadowsEnabled: true,

    // Shadow softness: 'sharp', 'soft', 'very-soft'
    shadowSoftness: 'soft',

    // Shadow opacity: 0 (invisible) to 1 (fully dark)
    shadowOpacity: 1.0,

    // Drop shadow enabled (shadow beneath book)
    dropShadowEnabled: true,

};

// =============================================
// TRANSLATIONS (i18n)
// =============================================
// Load translations for configured language (with English fallback)
// Translation helper function
function t(key, params = {}) {
    let str = i18nStrings[key] || i18nFallback[key] || key;
    for (const [k, v] of Object.entries(params)) {
        str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
    }
    return str;
}

// Apply translations to static HTML elements
function applyTranslations() {
    // Translate all elements with data-i18n attribute (textContent)
    document.querySelectorAll('[data-i18n]').forEach(el => {
        const key = el.getAttribute('data-i18n');
        el.textContent = t(key);
    });
    // Translate all elements with data-i18n-html attribute (innerHTML)
    document.querySelectorAll('[data-i18n-html]').forEach(el => {
        const key = el.getAttribute('data-i18n-html');
        el.innerHTML = t(key);
    });
    // Translate all elements with data-i18n-placeholder
    document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
        el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
    });
    // Translate all elements with data-i18n-aria-label
    document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
        el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label')));
    });
    // Translate all elements with data-i18n-title
    document.querySelectorAll('[data-i18n-title]').forEach(el => {
        el.title = t(el.getAttribute('data-i18n-title'));
    });
    // Translate optgroup labels
    document.querySelectorAll('[data-i18n-label]').forEach(el => {
        el.label = t(el.getAttribute('data-i18n-label'));
    });
    // Set document language
    document.documentElement.lang = CONFIG.language || 'en';
    // Set RTL direction for RTL languages
    if (rtlLanguages.includes(CONFIG.language)) {
        document.documentElement.dir = 'rtl';
        CONFIG.isRTL = true;
    }
}


// =============================================
// TTS (Text-to-Speech) CONFIGURATION
// Add your API keys here for premium voices
// =============================================
const DEFAULT_TTS_CONFIG = { defaultProvider: 'browser', defaultBrowserVoice: 'Google' };

// =============================================
// COLOR CONFIGURATION
// Customize the color scheme of the flipbook
// =============================================
const DEFAULT_COLOR_CONFIG = {
    // Active chrome — surface palette for toolbar/sidebar/popup/page background.
    // One of: 'dark' | 'light' | 'glass' | 'sepia'
    chrome: 'slate',

    // Initial polarity. 'auto' uses the chrome's natural defaultMode; 'dark' or
    // 'light' force a specific polarity. Reader can flip via toolbar toggle.
    colorMode: 'auto',

    // Single brand color drives every accent (primary buttons, active icons,
    // focus rings, scrollbar thumb). Hover/active shades are computed from this.
    brandColor: '#4a9eff',

    // Each chrome ships a paired { dark, light } palette so readers can flip
    // polarity from any starting chrome via the toolbar toggle.
    // Optional admin override for the hardcover wrap color. Empty string means
    // "derive from current chrome polarity" (each chrome ships its own dark/light
    // cover hex). Spine and bevel are auto-derived shades of this color.
    coverColor: '',

    chromes: {
        slate: {
            // Neutral cool-gray surfaces. Defaults to dark polarity.
            defaultMode: 'dark',
            dark: {
                pageBg:    '#0f0f12',
                toolbarBg: 'rgba(24, 24, 28, 0.96)',
                popupBg:   '#1c1c22',
                sidebarBg: '#16161b',
                text:      '#f5f5f7',
                border:    'rgba(255, 255, 255, 0.08)',
                cover:     '#1c1c22',
            },
            light: {
                pageBg:    '#f5f5f7',
                toolbarBg: 'rgba(255, 255, 255, 0.96)',
                popupBg:   '#ffffff',
                sidebarBg: '#fafafa',
                text:      '#1c1c22',
                border:    'rgba(0, 0, 0, 0.08)',
                cover:     '#f5f5f7',
            },
        },
    },
};

// Migrate legacy chrome names ('dark'→'slate', 'light'→'paper') saved before
// the rename. Unknown values pass through and get caught by the fallback.
function _normalizeChromeName(name) {
    if (name === 'dark')  return 'slate';
    if (name === 'light') return 'paper';
    return name;
}

// Helpers — chrome lookup and brand-color shading.
function getActiveChrome() {
    return COLOR_CONFIG.chromes[_normalizeChromeName(COLOR_CONFIG.chrome)] || COLOR_CONFIG.chromes.slate;
}
function _hexToRgbTuple(hex) {
    const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || '');
    return r ? [parseInt(r[1], 16), parseInt(r[2], 16), parseInt(r[3], 16)] : null;
}
function _shadeHex(hex, pct) {
    const rgb = _hexToRgbTuple(hex);
    if (!rgb) return hex;
    const delta = Math.round((pct / 100) * 255);
    const out = rgb.map(c => Math.max(0, Math.min(255, c + delta)).toString(16).padStart(2, '0'));
    return '#' + out.join('');
}

// Resolve the effective hardcover wrap color. Returns:
//   1. CONFIG.coverColor (admin override) if set
//   2. otherwise the active chrome's cover color for the current polarity
function resolveCoverColor(isDarkPolarity) {
    const override = (CONFIG.coverColor || '').trim();
    if (override) return override;
    const chrome = COLOR_CONFIG.chromes[_normalizeChromeName(COLOR_CONFIG.chrome)] || COLOR_CONFIG.chromes.slate;
    const variant = isDarkPolarity ? (chrome.dark || chrome) : (chrome.light || chrome);
    return variant.cover || (isDarkPolarity ? '#1c1c22' : '#f5f5f7');
}

// Resolve which polarity the reader should land on for a given chrome.
// 'auto' uses the chrome's defaultMode; 'dark'/'light' force.
function resolveColorMode(chromeName, requestedMode) {
    const chrome = COLOR_CONFIG.chromes[_normalizeChromeName(chromeName)] || COLOR_CONFIG.chromes.slate;
    if (requestedMode === 'dark' || requestedMode === 'light') return requestedMode;
    return chrome.defaultMode || 'dark';
}

// Apply chrome (surfaces) and brand color (accents) to CSS variables.
function applyColorScheme(chromeName, brandColor, colorMode) {
    chromeName = _normalizeChromeName(chromeName);
    const chrome = COLOR_CONFIG.chromes[chromeName] || COLOR_CONFIG.chromes.slate;
    const brand  = brandColor || COLOR_CONFIG.brandColor || '#4a9eff';
    const root   = document.documentElement;

    // Both polarities of the chosen chrome are written to vars; the in-viewer
    // toggle flips body.light-theme to swap which set is used.
    const darkSrc  = chrome.dark  || chrome;
    const lightSrc = chrome.light || chrome;

    root.style.setProperty('--bg-page',     darkSrc.pageBg);
    root.style.setProperty('--bg-toolbar',  darkSrc.toolbarBg);
    root.style.setProperty('--bg-popup',    darkSrc.popupBg);
    root.style.setProperty('--bg-sidebar',  darkSrc.sidebarBg);
    root.style.setProperty('--text-color',  darkSrc.text);
    root.style.setProperty('--border-color', darkSrc.border);

    root.style.setProperty('--bg-page-light',     lightSrc.pageBg);
    root.style.setProperty('--bg-toolbar-light',  lightSrc.toolbarBg);
    root.style.setProperty('--bg-popup-light',    lightSrc.popupBg);
    root.style.setProperty('--bg-sidebar-light',  lightSrc.sidebarBg);
    root.style.setProperty('--text-color-light',  lightSrc.text);
    root.style.setProperty('--border-color-light', lightSrc.border);

    // Brand-driven accents — derive hover/active shades from the single brand hex.
    root.style.setProperty('--color-primary',        brand);
    root.style.setProperty('--color-primary-hover',  _shadeHex(brand, -8));
    root.style.setProperty('--color-secondary',     brand);
    root.style.setProperty('--color-icon',           brand);
    root.style.setProperty('--color-icon-active',    _shadeHex(brand, +14));

    const tuple = _hexToRgbTuple(brand);
    const rgbStr = tuple ? `${tuple[0]}, ${tuple[1]}, ${tuple[2]}` : '74, 158, 255';
    root.style.setProperty('--color-primary-rgb', rgbStr);
    root.style.setProperty('--color-secondary-rgb', rgbStr);

    // Polarity: read mode (auto resolves to the chrome's defaultMode).
    const mode = resolveColorMode(chromeName, colorMode);
    document.body.classList.toggle('light-theme', mode === 'light');

    // Chrome-specific class for backdrop-filter and other chrome-only rules.
    document.body.classList.remove(
        'chrome-slate', 'chrome-paper', 'chrome-glass', 'chrome-sepia',
        'chrome-midnight', 'chrome-ocean', 'chrome-forest',
        'chrome-dark', 'chrome-light'
    );
    document.body.classList.add('chrome-' + chromeName);

    // Drop the server-side anti-flash inline style now that CSS variables are
    // active. Otherwise its hardcoded background overrides theme toggles and
    // bleeds the original mode through padding/scroll areas.
    const initStyle = document.getElementById('tncfb-init-bg');
    if (initStyle) initStyle.remove();
}

// WebGL Support Check
function hasWebGLSupport() {
    try {
        const canvas = document.createElement('canvas');
        return !!(canvas.getContext('webgl2') || canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
    } catch (e) {
        return false;
    }
}

/**
 * Determine the effective render mode based on CONFIG.renderMode and hardware support.
 * Returns 'auto' resolved to either '3d' or '2d'.
 */
function resolveRenderMode() {
    const mode = CONFIG.renderMode || 'auto';
    if (mode === '3d') return '3d';
    if (mode === '2d') return '2d';
    // auto: use 3D if WebGL is available
    return hasWebGLSupport() ? '3d' : '2d';
}

// =============================================
// CONTENT PROTECTION SYSTEM
// =============================================
let contentProtection = null;

// =============================================
// ACCESSIBILITY MANAGER (WCAG 2.1 AA)
// =============================================
class AccessibilityManager {
    constructor(flipbook) {
        this.flipbook = flipbook;
        this.active = true;
        this.liveRegion = null;
        this.savedFocus = null;
        this._trapHandler = null;
        this._activeModal = null;
        this.prefersReducedMotion = false;

        if (!CONFIG.accessibility?.enabled) return;
        this.init();
    }

    init() {
        this.active = true; // Always on
        this.setupSkipLinks();
        this.setupLiveRegion();
        this.setupKeyboardShortcuts();
        this.setupReducedMotion();
        this.setupKeyboardHelpModal();
        this.applyState();
    }


    // Apply active state to the page
    applyState() {
        document.documentElement.classList.add('a11y-active');

        const skipLink = document.querySelector('.skip-link');
        if (skipLink) skipLink.style.display = '';

        if (this.liveRegion) {
            this.liveRegion.setAttribute('aria-live', 'polite');
        }
    }

    // Insert skip link at top of body
    setupSkipLinks() {
        if (!CONFIG.accessibility?.skipLinks) return;

        const skipLink = document.createElement('a');
        skipLink.href = '#canvas-container';
        skipLink.className = 'skip-link';
        skipLink.textContent = t('a11y.skipToFlipbook');
        document.body.insertBefore(skipLink, document.body.firstChild);

        const target = document.getElementById('canvas-container');
        if (target) target.setAttribute('tabindex', '-1');
    }

    // Create invisible live region for screen reader announcements
    setupLiveRegion() {
        if (!CONFIG.accessibility?.screenReaderAnnouncements) return;

        this.liveRegion = document.createElement('div');
        this.liveRegion.setAttribute('role', 'status');
        this.liveRegion.setAttribute('aria-live', 'polite');
        this.liveRegion.setAttribute('aria-atomic', 'true');
        this.liveRegion.className = 'sr-only';
        this.liveRegion.id = 'a11y-live-region';
        document.body.appendChild(this.liveRegion);
    }

    // Announce a message to screen readers via the live region
    announce(message) {
        if (!this.active || !this.liveRegion) return;
        this.liveRegion.textContent = '';
        requestAnimationFrame(() => {
            this.liveRegion.textContent = message;
        });
    }

    // Enhanced keyboard navigation
    setupKeyboardShortcuts() {
        if (!CONFIG.accessibility?.keyboardShortcuts) return;

        document.addEventListener('keydown', (e) => {
            // Don't intercept when typing in inputs
            if (e.target.matches('input, textarea, select')) return;
            if (!this.active) return;

            // Don't conflict with content protection keyboard blocking
            if (e.ctrlKey || e.metaKey) return;

            // Arrow keys, Home/End are handled by the Flipbook class natively.
            // Accessibility manager adds extra shortcuts only.
            switch (e.key) {
                case 'f':
                    if (!document.fullscreenElement) {
                        document.documentElement.requestFullscreen();
                    } else {
                        document.exitFullscreen();
                    }
                    e.preventDefault();
                    break;
                case '/':
                    e.preventDefault();
                    this.focusSearch();
                    break;
                case '?':
                    e.preventDefault();
                    this.showKeyboardHelp();
                    break;
                case 'Escape':
                    this.closeActiveOverlay();
                    break;
            }
        });
    }

    // Focus the search input
    focusSearch() {
        const searchBtn = document.getElementById('searchBtn');
        const searchInput = document.getElementById('searchInput');
        const searchSidebar = document.getElementById('searchSidebar');

        if (searchSidebar && !searchSidebar.classList.contains('open')) {
            if (searchBtn) searchBtn.click();
        }
        if (searchInput) {
            setTimeout(() => searchInput.focus(), 100);
        }
    }

    // Close the topmost open overlay/modal/sidebar
    closeActiveOverlay() {
        // Check keyboard help modal
        const helpModal = document.getElementById('keyboardHelpModal');
        if (helpModal && helpModal.classList.contains('active')) {
            this.hideKeyboardHelp();
            return;
        }

        // Release trapped focus if any modal is active
        if (this._activeModal) {
            this.releaseFocus(this._activeModal);
            this._activeModal = null;
        }
    }

    // Show keyboard shortcuts help modal
    showKeyboardHelp() {
        if (!CONFIG.accessibility?.keyboardShortcutsHelp) return;
        const modal = document.getElementById('keyboardHelpModal');
        if (modal) {
            modal.classList.add('active');
            this.trapFocus(modal);
        }
    }

    // Hide keyboard shortcuts help modal
    hideKeyboardHelp() {
        const modal = document.getElementById('keyboardHelpModal');
        if (modal) {
            modal.classList.remove('active');
            this.releaseFocus(modal);
        }
    }

    // Setup keyboard help modal close button
    setupKeyboardHelpModal() {
        const modal = document.getElementById('keyboardHelpModal');
        if (!modal) return;

        // Close on X button
        const closeBtn = document.getElementById('keyboardHelpClose');
        if (closeBtn) {
            closeBtn.addEventListener('click', () => this.hideKeyboardHelp());
        }

        // Close on footer button
        const footerClose = modal.querySelector('.keyboard-help-close');
        if (footerClose && footerClose !== closeBtn) {
            footerClose.addEventListener('click', () => this.hideKeyboardHelp());
        }

        // Close on backdrop click
        modal.addEventListener('click', (e) => {
            if (e.target === modal) this.hideKeyboardHelp();
        });
    }

    // Trap focus within a modal element
    trapFocus(modal) {
        const focusable = modal.querySelectorAll(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        if (!focusable.length) return;

        const first = focusable[0];
        const last = focusable[focusable.length - 1];
        this.savedFocus = document.activeElement;
        this._activeModal = modal;
        first.focus();

        this._trapHandler = (e) => {
            if (e.key === 'Escape') {
                e.preventDefault();
                e.stopPropagation();
                this.closeActiveOverlay();
                return;
            }
            if (e.key !== 'Tab') return;
            if (e.shiftKey && document.activeElement === first) {
                e.preventDefault();
                last.focus();
            } else if (!e.shiftKey && document.activeElement === last) {
                e.preventDefault();
                first.focus();
            }
        };
        modal.addEventListener('keydown', this._trapHandler);
    }

    // Release focus trap and restore previous focus
    releaseFocus(modal) {
        if (this._trapHandler) {
            modal.removeEventListener('keydown', this._trapHandler);
            this._trapHandler = null;
        }
        this._activeModal = null;
        if (this.savedFocus) {
            this.savedFocus.focus();
            this.savedFocus = null;
        }
    }

    // Detect and respond to prefers-reduced-motion
    setupReducedMotion() {
        if (!CONFIG.accessibility?.reducedMotion) return;

        const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
        this.prefersReducedMotion = mq.matches;
        mq.addEventListener('change', (e) => {
            this.prefersReducedMotion = e.matches;
        });
    }

}

// =============================================
// GOOGLE ANALYTICS 4 TRACKING
// =============================================
let analytics = { init(){}, track(){}, trackPageTime(){}, trackPageView(){}, trackPageFlip(){}, trackDocumentLoaded(){}, trackDocumentError(){}, trackZoom(){}, trackFullscreen(){}, trackThemeChange(){}, trackSearch(){}, trackSearchNavigate(){}, trackDownload(){}, trackPrint(){}, trackShare(){}, trackReadAloud(){}, trackBookmark(){}, trackNote(){}, trackThumbnails(){}, trackTableOfContents(){}, trackViewMode(){}, trackFlipbookStyle(){}, trackSinglePageMode(){}, trackAutoPlay(){}, trackSound(){}, trackLinkClick(){}, trackMediaPlay(){}, trackTilt(){}, trackRotation(){}, trackReadingMode(){}, trackTextSelection(){}, trackTextCopy(){} };

class Flipbook {
    constructor() {
        // Capture initial hash before any page-render side-effects can replace it.
        this._initialHash = (typeof location !== 'undefined' ? location.hash : '') || '';

        // Resize the protection watermark to the page area on viewport changes.
        // (Per-frame refits happen inside updatePeelOverlays / updatePageIndicator.)
        window.addEventListener('resize', () => {
            try { this.repositionWatermark && this.repositionWatermark(); } catch (e) {}
        });
        // Initialize loading logo from CONFIG (do this first so it shows immediately)
        const loadingLogo = document.getElementById('loadingLogo');
        if (loadingLogo) {
            const isLight = document.body.classList.contains('light-theme');
            // In transparent popups the loading screen sits over the popup
            // overlay, whose polarity is set by `popuptheme`, not by the
            // page's chrome. Pick the logo against the actual backdrop.
            const popupLight = document.body.classList.contains('tncfb-popup-theme-light');
            const popupDark  = document.body.classList.contains('tncfb-popup-theme-dark');
            const isTransparent = !!CONFIG.transparentBackground;
            const useDarkLogo = (isTransparent && (popupLight || popupDark))
                ? popupLight
                : isLight;
            const logoSrc = useDarkLogo && CONFIG.loadingLogoDark
                ? CONFIG.loadingLogoDark
                : CONFIG.loadingLogo;
            if (logoSrc) {
                loadingLogo.src = logoSrc;
                if (CONFIG.loadingLogoMaxWidth) {
                    loadingLogo.style.maxWidth = CONFIG.loadingLogoMaxWidth + 'px';
                }
                loadingLogo.style.display = 'block';
            }
        }

        this._loadStartTime = Date.now(); // Track load time for analytics

        // Determine render mode (3D or 2D CSS fallback)
        this.is2D = resolveRenderMode() === '2d';

        // Transparent background flag — set early so both 3D and 2D modes can use it
        this._isTransparentBg = !!CONFIG.transparentBackground;

        // Force transparent on all containers if enabled
        if (this._isTransparentBg) {
            document.documentElement.style.background = 'transparent';
            document.body.style.background = 'transparent';
            document.body.classList.add('tncfb-transparent');
            const app = document.getElementById('app');
            if (app) app.style.background = 'transparent';
        }

        this.container = document.getElementById('canvas-container');
        this.currentSpread = 0;
        this.isAnimating = false;
        this.pages = [];
        this.book = null;
        this.zoomLevel = 100;
        this.zoomMode = null; // null, 'auto', 'page-fit', 'page-width'
        this.isDarkTheme = resolveColorMode(COLOR_CONFIG.chrome, COLOR_CONFIG.colorMode) === 'dark';
        this.pdfDoc = null;
        this.pageTextures = [];
        this.pageDataURLs = []; // Store page data URLs for peel effect (lazy generated)
        this.pageCanvases = []; // Store page canvases for lazy data URL generation
        this.pageTextContent = []; // Store text content for search
        this.pageAnnotations = []; // Store link annotations per page
        this._linkElements = []; // Current link overlay DOM elements
        this.pageMediaAnnotations = []; // Multimedia annotations per page
        this._mediaElements = []; // Current media overlay DOM elements
        this._activeMediaPlayers = {}; // Active video/audio players by annotId
        this._active3DViewers = {}; // Active 3D viewers by annotId
        this._pageVideoElements = []; // Page video overlay DOM elements
        this._activePageVideos = {}; // Active page video players by page number
        this.numPages = 0;
        this.totalSpreads = 0;

        // Search state
        this.searchResults = [];
        this.currentSearchIndex = -1;

        // Hover state for corner lift
        this.hoveredCorner = null;
        this.hoveredPage = null;

        // Drag flip state
        this.isDragging = false;
        this.dragPending = false; // True when mousedown but not yet moved enough
        this.dragStartX = 0;
        this.dragStartY = 0;
        this.dragPage = null;
        this.dragDirection = null; // 'next' or 'prev'
        this.dragProgress = 0;
        this.dragThreshold = 10; // Pixels to move before drag starts

        // View mode for single/double page
        this.viewMode = 'double'; // 'single' or 'double'
        this.targetBookX = 0;
        this.isTransitioning = false;

        // Sound
        this.soundEnabled = true;
        this.flipAudio = null;
        this.initAudio();

        // Pan state
        this.panX = 0;
        this.panY = 0;
        this.isPanning = false;
        this.handToolActive = false;
        this.panStartX = 0;
        this.panStartY = 0;
        this.lastPanX = 0;
        this.lastPanY = 0;

        // Rotation state
        this.isTilted = true; // Skew/tilt view (starts tilted)
        this.rotationAngle = 0; // 0, 90, 180, 270 degrees

        // More options state
        this.autoPlayEnabled = false;
        this.autoPlayInterval = null;
        this.autoPlayDelay = 3000; // 3 seconds between pages

        // Progressive loading state
        this.backgroundLoadComplete = false;
        this._backgroundLoadStart = null;
        this.forceSinglePage = CONFIG.defaultViewMode === 'single';
        this._mobileSinglePageActive = false;
        if (CONFIG.forceSinglePageMobile && window.innerWidth < 768) {
            this.forceSinglePage = true;
            this._mobileSinglePageActive = true;
        }
        this.isEvenPageMode = CONFIG.pageMode === 'even';
        this.minFlipped = this.isEvenPageMode ? 1 : 0;
        this.currentSinglePage = 1; // Track current page in single page mode
        this._lastEmittedPage = 0; // Track last emitted page for event dedup
        this.bookmarks = [];
        this.pdfOutline = []; // PDF built-in outline/table of contents
        this.notes = [];

        // Config hotspots state
        this._configHotspotElements = [];
        this._activeConfigPopupId = null;

        // Reading mode state
        this.isReadingMode = false;
        this.readingModePreState = null;

        // Vertical viewer state
        this.isVerticalViewer = false;
        this.verticalViewerPreState = null;
        this.verticalViewerScrollHandler = null;
        this._verticalCurrentPage = null;

        // Horizontal viewer state
        this.isHorizontalViewer = false;
        this.horizontalViewerPreState = null;
        this.horizontalViewerScrollHandler = null;
        this._horizontalCurrentPage = null;

        // Form viewer state
        this.isFormViewer = false;
        this.formViewerPreState = null;
        this.annotationStorage = null;
        this._formCurrentPage = null;
        this.formActiveTool = 'select';
        this.formAnnotations = []; // Custom annotations (signatures, text, etc.)
        this.signatureDataURL = null; // Saved signature
        this.initialsDataURL = null; // Saved initials
        this.signatureCtx = null;
        this.isDrawingSignature = false;

        // RTL state
        this.isRTL = CONFIG.isRTL || false;

        // Flipbook style state
        this.flipbookStyle = CONFIG.flipbookStyle || 'default';
        this.hardcoverGroup = null;
        this.frontCover = null;
        this.backCover = null;
        this.spine = null;
        this.spiralCoils = null;

        // Read Aloud state
        this.speechSynthesis = window.speechSynthesis;
        this.speechUtterance = null;
        this.speechVoices = [];
        const ttsDefaults = TTS_CONFIG || {};
        this.speechRate = ttsDefaults.defaultSpeed || 1;
        this.speechPitch = 1;
        this.speechVoice = null;
        this.isReading = false;
        this.isPaused = false;
        this.autoFlipEnabled = ttsDefaults.defaultAutoFlip !== undefined ? ttsDefaults.defaultAutoFlip : true;
        this.currentSentences = [];
        this.currentSentenceIndex = 0;
        this.speedLevels = [0.5, 0.75, 1, 1.25, 1.5, 2];
        this.currentSpeedIndex = this.speedLevels.indexOf(this.speechRate);
        if (this.currentSpeedIndex < 0) this.currentSpeedIndex = 2; // fallback to 1x

        this.ttsProvider = 'browser';

        // Apply initial RTL mode
        if (this.isRTL) {
            document.body.classList.add('rtl-mode');
        }

        this.init();
        this.loadContent().then(() => {
            try {
                this.createBook();
                // Initialize flipbook style if not default (3D only)
                if (!this.is2D && this.flipbookStyle !== 'default') {
                    if (this.flipbookStyle === 'hardcover') {
                        this.setupHardcoverStyle();
                    } else if (this.flipbookStyle === 'spiral') {
                        this.setupSpiralStyle();
                    }
                    document.body.classList.add(`flipbook-${this.flipbookStyle}`);
                    this.updateHardcoverVisibility();
                    this.updateSpiralPosition();
                }
                this.setupControls();
                this.setupToolbar();
                if (typeof this.setupNoteEditor === 'function') this.setupNoteEditor();
                this.setupReadAloud();
                this.createThumbnails();

                // Apply toolbar visibility config (hide disabled items)
                this.applyToolbarConfig();

                // Hide bookmarks button if no outline and no user bookmarks exist
                if ((!this.pdfOutline || this.pdfOutline.length === 0) &&
                    (!this.bookmarks || this.bookmarks.length === 0)) {
                    const bOpt = document.getElementById('bookmarksOption');
                    if (bOpt) bOpt.style.display = 'none';
                }

                // Adjust UI for image-based flipbooks
                if (this.isImageBased) {
                    this.setupImageBasedUI();
                }

                // Auto-open a startup sidebar if the admin configured one (Pro).
                // Runs after toolbar gating + image-UI so it respects disabled items.
                this.applyStartupPanel();

                // Complete progress and fade out
                const progressBar = document.getElementById('progressBar');
                const progressText = document.getElementById('progressText');
                const loadingTitle = document.querySelector('.loading-title');
                const loadingEl = document.getElementById('loading');

                progressBar.style.width = '100%';
                loadingTitle.textContent = t('loading.done.title');
                progressText.textContent = t('loading.done.text');

                // Track document loaded in analytics
                const loadTimeMs = Date.now() - this._loadStartTime;
                analytics.trackDocumentLoaded(this.numPages, loadTimeMs, this.isImageBased);

                // Emit loading:complete and ready events
                if (this._tncfb) {
                    this._tncfb.emit('loading:complete', { totalPages: this.numPages });
                    this._tncfb.emit('ready', { totalPages: this.numPages });
                    // Resolve the ready() promise
                    if (!this._tncfb._readyResolved) {
                        this._tncfb._readyResolved = true;
                        this._tncfb._readyResolve();
                    }
                }

                // Set initial tilt button state
                document.getElementById('tiltBtn').classList.add('active');

                // Apply default zoom BEFORE showing the flipbook (prevents visible zoom shift)
                if (this.numPages > 0) {
                    const dz = CONFIG.defaultZoom || 'auto';
                    if (dz === 'auto' || dz === 'page-fit' || dz === 'page-width') {
                        this.setZoom(100, dz);
                    } else {
                        const level = parseInt(dz, 10);
                        if (!isNaN(level)) this.setZoom(level);
                    }
                }

                // Fade out the loading screen
                setTimeout(() => {
                    loadingEl.style.opacity = '0';
                    loadingEl.style.transition = 'opacity 0.3s ease';
                    setTimeout(() => {
                        loadingEl.style.display = 'none';
                    }, 300);

                    // Start background loading of remaining pages
                    this.loadRemainingPages().then(() => {
                        // Definitive startup search once every page's text is
                        // extracted, so multi-page PDFs return complete results.
                        if (this._startupSearchQuery) {
                            this.performSearch(this._startupSearchQuery);
                            this._startupSearchQuery = null;
                        }
                        // Refresh viewer mode pages after all pages are loaded
                        if (this.isVerticalViewer) {
                            const currentPage = this._verticalCurrentPage || 1;
                            this.populateVerticalViewer();
                            requestAnimationFrame(() => {
                                const el = document.getElementById(`vertical-page-${currentPage}`);
                                if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
                            });
                        } else if (this.isHorizontalViewer) {
                            const currentPage = this._horizontalCurrentPage || 1;
                            this.populateHorizontalViewer();
                            requestAnimationFrame(() => {
                                const el = document.getElementById(`horizontal-page-${currentPage}`);
                                if (el) el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
                            });
                        }
                    });
                }, 200);

                this.updatePageIndicator();

                // Apply default view mode from config (enter immediately with initial pages;
                // loadRemainingPages will refresh the viewer once all pages are ready)
                if (CONFIG.defaultViewMode && CONFIG.defaultViewMode !== 'flipbook') {
                    switch (CONFIG.defaultViewMode) {
                        case 'single':
                            // forceSinglePage already set in init — just activate button
                            { const _spBtn = document.getElementById('singlePageBtn'); if (_spBtn) _spBtn.classList.add('active'); }
                            break;
                        case 'vertical':
                            this.enterVerticalViewer();
                            break;
                        case 'horizontal':
                            this.enterHorizontalViewer();
                            break;
                    }
                }

                // Check for page parameter in URL (for deep linking)
                this.checkPageParameter();
            } catch (setupErr) {
                // Content loaded but setup failed - log error but don't show failure screen
                console.error('Setup error after content loaded:', setupErr);
                // Still hide loading screen since content is loaded
                const loadingEl = document.getElementById('loading');
                loadingEl.style.opacity = '0';
                setTimeout(() => { loadingEl.style.display = 'none'; }, 300);
            }
        }).catch(err => {
            console.error('Failed to load content:', err);
            analytics.trackDocumentError(err.message);
            // Only show fatal error if no pages have loaded yet
            if (this.numPages > 0) {
                console.warn('Non-fatal PDF error (pages already loaded):', err.message);
                return;
            }
            const loadingEl = document.getElementById('loading');
            const isImageBased = CONFIG.imageSources && CONFIG.imageSources.length > 0;
            loadingEl.innerHTML = `
                <div class="loading-title" style="color: #ff6b6b;">Failed to load ${isImageBased ? 'images' : 'PDF'}</div>
                <div class="progress-text" style="opacity: 0.7;">${err.message}</div>
                <div class="progress-text" style="margin-top: 8px; opacity: 0.5;">Check CONFIG.${isImageBased ? 'imageSources' : 'pdfSource'} in the code</div>
            `;
        });
        this.animate();

        // Resume the 3D render loop when the tab becomes visible again.
        if (CONFIG.memoryOptimized !== false && !this._visibilityListenerAttached) {
            this._visibilityListenerAttached = true;
            document.addEventListener('visibilitychange', () => {
                if (!document.hidden && !this.is2D && !this._rafId) {
                    this.animate();
                }
            });
        }
    }

    // Main content loader - decides whether to load PDFs or images
    async loadContent() {
        // Check if imageSources is provided - if so, load images instead of PDFs
        if (CONFIG.imageSources && CONFIG.imageSources.length > 0) {
            return this.loadImages();
        }
        // Otherwise load PDFs
        return this.loadPDF();
    }

    // Load images as flipbook pages
    async loadImages() {
        const progressBar = document.getElementById('progressBar');
        const progressText = document.getElementById('progressText');
        const loadingTitle = document.querySelector('.loading-title');

        const loadingPhrases = [
            'Loading images...',
            'Preparing pages...',
            'Almost ready...',
            'Setting up flipbook...',
        ];

        const getRandomPhrase = () => loadingPhrases[Math.floor(Math.random() * loadingPhrases.length)];

        loadingTitle.textContent = t('loading.preparing');
        progressText.textContent = t('loading.images');
        progressBar.style.width = '5%';

        // Mark as image-based flipbook (no PDF features)
        this.isImageBased = true;
        this.pdfDocs = [];
        this.pdfSections = [];

        const imageSources = CONFIG.imageSources;
        this.numPages = imageSources.length;
        this.totalSpreads = Math.ceil(this.numPages / 2);

        // Track sections for images with names
        let currentSection = null;
        for (let i = 0; i < imageSources.length; i++) {
            const imgInfo = imageSources[i];
            const imgSrc = typeof imgInfo === 'string' ? imgInfo : imgInfo.src;
            const imgName = typeof imgInfo === 'string' ? null : imgInfo.name;

            // Create new section if image has a name
            if (imgName) {
                if (currentSection) {
                    currentSection.endPage = i; // Previous section ends here
                }
                currentSection = {
                    name: imgName,
                    startPage: i + 1,
                    endPage: imageSources.length, // Will be updated if another section starts
                    pageCount: 0,
                    pdfIndex: this.pdfSections.length
                };
                this.pdfSections.push(currentSection);
            }
        }

        // If no sections were created, create a default one
        if (this.pdfSections.length === 0) {
            this.pdfSections.push({
                name: 'Images',
                startPage: 1,
                endPage: this.numPages,
                pageCount: this.numPages,
                pdfIndex: 0
            });
        }

        // Update section page counts
        for (let i = 0; i < this.pdfSections.length; i++) {
            const section = this.pdfSections[i];
            section.pageCount = section.endPage - section.startPage + 1;
        }

        progressBar.style.width = '10%';

        // Pre-allocate slots so concurrent loads land in the right order
        const total = imageSources.length;
        this.pageTextures = new Array(total);
        this.pageDataURLs = new Array(total);
        this.pageTextContent = new Array(total).fill('');
        this.pageAnnotations = new Array(total).fill(null).map(() => []);
        this.pageMediaAnnotations = new Array(total).fill(null).map(() => []);

        // Load images in parallel batches of 8 — fast without thrashing the network/decoder
        const BATCH_SIZE = 8;
        let firstImageLoaded = false;
        let completed = 0;

        const loadOne = async (i) => {
            const imgInfo = imageSources[i];
            const imgSrc = typeof imgInfo === 'string' ? imgInfo : imgInfo.src;
            try {
                const texture = await this.loadImageTexture(imgSrc);
                this.pageTextures[i] = texture;
                this.pageDataURLs[i] = imgSrc;
                if (!firstImageLoaded && texture.image) {
                    const aspectRatio = texture.image.width / texture.image.height;
                    CONFIG.pageHeight = CONFIG.pageWidth / aspectRatio;
                    firstImageLoaded = true;
                }
            } catch (err) {
                console.warn(`Failed to load image ${i + 1}:`, imgSrc, err);
                this.pageTextures[i] = this.createPlaceholderTexture(`Image ${i + 1} failed to load`);
                this.pageDataURLs[i] = '';
            }
            completed++;
            const progress = 10 + (completed / total) * 85;
            progressBar.style.width = `${progress}%`;
            if (this._tncfb) this._tncfb.emit('loading:progress', { loaded: completed, total, percent: Math.round(progress) });
            if (completed % 2 === 0) progressText.textContent = getRandomPhrase();
        };

        for (let start = 0; start < total; start += BATCH_SIZE) {
            const end = Math.min(start + BATCH_SIZE, total);
            const batch = [];
            for (let i = start; i < end; i++) batch.push(loadOne(i));
            await Promise.all(batch);
        }

        // Apply custom table of contents for image-based flipbooks
        if (Array.isArray(CONFIG.tableOfContents) && CONFIG.tableOfContents.length > 0) {
            this.pdfOutline = CONFIG.tableOfContents
                .filter(e => e && e.title && e.page)
                .map(e => ({ title: String(e.title), page: parseInt(e.page, 10), children: [] }));
        }

        progressBar.style.width = '95%';
    }

    // Load a single image as a Three.js texture
    async loadImageTexture(src) {
        return new Promise((resolve, reject) => {
            const loader = new THREE.TextureLoader();
            loader.load(
                src,
                (texture) => {
                    texture.colorSpace = THREE.SRGBColorSpace;
                    texture.minFilter = THREE.LinearFilter;
                    texture.magFilter = THREE.LinearFilter;
                    texture.generateMipmaps = false;
                    resolve(texture);
                },
                undefined,
                (err) => reject(err)
            );
        });
    }

    // Create a placeholder texture for failed images
    createPlaceholderTexture(message = 'Image not found') {
        const canvas = document.createElement('canvas');
        canvas.width = 800;
        canvas.height = 1000;
        const ctx = canvas.getContext('2d');

        // White background
        ctx.fillStyle = '#f5f5f5';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Border
        ctx.strokeStyle = '#ddd';
        ctx.lineWidth = 4;
        ctx.strokeRect(20, 20, canvas.width - 40, canvas.height - 40);

        // Icon placeholder
        ctx.fillStyle = '#ccc';
        ctx.font = '120px Arial';
        ctx.textAlign = 'center';
        ctx.fillText('🖼️', canvas.width / 2, canvas.height / 2 - 50);

        // Message
        ctx.fillStyle = '#999';
        ctx.font = '24px Arial';
        ctx.fillText(message, canvas.width / 2, canvas.height / 2 + 80);

        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return texture;
    }

    // Apply toolbar visibility from CONFIG.toolbar
    applyToolbarConfig() {
        const tb = CONFIG.toolbar;
        if (!tb) return;

        const hide = (id) => {
            const el = document.getElementById(id);
            if (el) el.style.display = 'none';
        };

        // The "more" menu holds overflow twins of several toolbar buttons
        // (moreShareBtn, moreDownloadBtn, ...). On narrow screens a CSS rule
        // force-shows them with `display:flex !important`, so a plain inline
        // hide loses to it. Hiding via a class lets a compound, equal-or-higher
        // specificity `!important` rule win, keeping disabled items hidden on
        // mobile as well as desktop.
        const hideMore = (id) => {
            const el = document.getElementById(id);
            if (el) el.classList.add('tncfb-config-hidden');
        };

        // Main toolbar buttons (and their "more" menu twins)
        if (tb.share === false) { hide('shareBtn'); hideMore('moreShareBtn'); }
        if (tb.download === false) { hide('downloadBtn'); hideMore('moreDownloadBtn'); }
        if (tb.print === false) { hide('printBtn'); hideMore('morePrintBtn'); }
        if (tb.search === false) { hide('searchBtn'); hideMore('moreSearchBtn'); }

        if (tb.tilt === false || this.is2D) { hide('tiltBtn'); hideMore('moreTiltBtn'); }
        if (tb.rotate === false) { hide('rotateBtn'); hideMore('moreRotateBtn'); }
        if (tb.zoom === false) {
            const zoomDropdown = document.querySelector('.zoom-dropdown');
            if (zoomDropdown) zoomDropdown.style.display = 'none';
            hide('zoomOutBtn');
            hide('zoomInBtn');
            hideMore('moreZoomOutBtn');
            hideMore('moreZoomInBtn');
        }
        if (tb.readingMode === false) { hide('readingModeBtn'); hideMore('moreReadingModeBtn'); }
        if (tb.readAloud === false) { hide('readAloudBtn'); hideMore('moreReadAloudBtn'); }
        if (tb.fullscreen === false) hide('fullscreenBtn');
        if (tb.themeToggle === false) {
            hide('themeToggleBtn');
            hide('themeToggleCtrl');
        }
        if (tb.moreMenu === false) {
            hide('moreBtn');
            hide('morePopup');
        }
        if (tb.pageIndicator === false) hide('pageIndicator');
        if (tb.logo === false) {
            const logo = document.querySelector('.toolbar-logo');
            if (logo) logo.style.display = 'none';
        }
        // More menu items
        if (tb.autoPlay === false) hide('autoPlayOption');
        if (tb.sound === false) hide('soundOption');
        if (tb.singlePage === false) hide('singlePageOption');
        if (tb.bookmarks === false) hide('bookmarksOption');
        if (tb.notes === false) hide('writeNoteOption');
        if (tb.thumbnails === false) hide('thumbnailsOption');
        // View mode switchers (3D/Vertical/Horizontal) are always hidden from the frontend.
        // Users switch view modes via the Default View Mode admin setting instead.
        hide('flipbookViewOption');
        hide('verticalViewerOption');
        hide('horizontalViewerOption');
        if (tb.formViewer === false) hide('formViewerOption');
        if (tb.flipbookStyles === false || this.is2D) {
            hide('styleDefaultOption');
            hide('styleHardcoverOption');
            hide('styleSpiralOption');
        }
        // Navigation arrows (prev/next)
        if (tb.navigation === false) {
            hide('prevBtn');
            hide('nextBtn');
        }
        // First/Last page arrows
        if (tb.firstLast === false) {
            hide('firstBtn');
            hide('lastBtn');
        }

        // Clean up section labels and dividers in more menu
        this.cleanupMoreMenuSections();

        // Clean up orphaned toolbar dividers
        this.cleanupToolbarDividers();
    }

    // Auto-open a sidebar on load when the admin configured one (Pro feature;
    // CONFIG.startupPanel is only emitted by the Pro config builder). Respects
    // toolbar gating, content availability, and the per-FlipBook mobile choice.
    // For search, the input is prefilled immediately and the actual search runs
    // here if text is ready; loadRemainingPages() triggers a definitive pass
    // once every page's text has been extracted (see _startupSearchQuery).
    applyStartupPanel() {
        const panel = CONFIG.startupPanel;
        if (!panel || panel === 'none') return;

        // Sidebars overlay the page on small screens — honour the admin's choice.
        if (CONFIG.startupPanelOnMobile === false && window.innerWidth < 768) return;

        const tb = CONFIG.toolbar || {};

        if (panel === 'bookmarks') {
            if (tb.bookmarks === false) return;
            const hasContent = (this.pdfOutline && this.pdfOutline.length > 0) ||
                               (this.bookmarks && this.bookmarks.length > 0);
            if (!hasContent) return;
            this.openBookmarksSidebar();
        } else if (panel === 'thumbnails') {
            if (tb.thumbnails === false) return;
            const sb = document.getElementById('thumbnailsSidebar');
            if (sb) sb.classList.add('active');
        } else if (panel === 'search') {
            if (tb.search === false || this.isImageBased) return;
            const sb = document.getElementById('searchSidebar');
            if (sb) sb.classList.add('active');
            const query = (CONFIG.startupSearchQuery || '').trim();
            const input = document.getElementById('searchInput');
            if (input) {
                input.value = query;
                setTimeout(() => input.focus(), 100);
            }
            if (query) {
                // Run an immediate pass over whatever text is already loaded, and
                // remember the query so loadRemainingPages() can run a complete
                // pass once every page's text is extracted.
                this._startupSearchQuery = query;
                this.performSearch(query);
            }
        }
    }

    setHandTool(active) {
        this.handToolActive = active;
        this.container.style.cursor = active ? 'grab' : '';
        document.body.classList.toggle('tncfb-hand-tool', !!active);
    }

    // Auto-enable hand tool when the book grows beyond the viewport. Called
    // from setZoom in 3D mode; vertical/horizontal viewers force it on
    // separately on enter.
    updateHandToolForZoom() {
        if (this.isVerticalViewer || this.isHorizontalViewer) return;
        this.setHandTool(this.zoomLevel > 100);
    }

    // Click-and-drag-to-scroll for vertical/horizontal viewer containers.
    // Returns a handle with detach() to remove all listeners on exit.
    attachDragScroll(container) {
        let isDown = false;
        let startX = 0, startY = 0, startLeft = 0, startTop = 0;
        let moved = false;

        const onDown = (e) => {
            if (e.button !== 0) return;
            // Skip clicks on interactive overlay elements (links, hotspots, video)
            if (e.target.closest('a, button, input, video, .pdf-link-overlay-link, .note-hotspot, .config-hotspot, .video-overlay')) return;
            // Prevent native image drag-ghost
            e.preventDefault();
            isDown = true;
            moved = false;
            startX = e.clientX;
            startY = e.clientY;
            startLeft = container.scrollLeft;
            startTop = container.scrollTop;
            container.style.cursor = 'grabbing';
            container.style.userSelect = 'none';
        };
        const onDragStart = (e) => e.preventDefault();
        const onMove = (e) => {
            if (!isDown) return;
            const dx = e.clientX - startX;
            const dy = e.clientY - startY;
            if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
            container.scrollLeft = startLeft - dx;
            container.scrollTop = startTop - dy;
        };
        const onUp = () => {
            if (!isDown) return;
            isDown = false;
            container.style.cursor = 'grab';
            container.style.userSelect = '';
        };
        const onClickCapture = (e) => {
            // Suppress click events that fire after a drag-scroll
            if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; }
        };

        container.style.cursor = 'grab';
        container.addEventListener('mousedown', onDown);
        document.addEventListener('mousemove', onMove);
        document.addEventListener('mouseup', onUp);
        container.addEventListener('click', onClickCapture, true);
        container.addEventListener('dragstart', onDragStart);

        return {
            detach: () => {
                container.removeEventListener('mousedown', onDown);
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup', onUp);
                container.removeEventListener('click', onClickCapture, true);
                container.removeEventListener('dragstart', onDragStart);
                container.style.cursor = '';
                container.style.userSelect = '';
            },
        };
    }

    // Hide more-popup section titles and dividers when all items in a section are hidden
    cleanupMoreMenuSections() {
        const popup = document.getElementById('morePopup');
        if (!popup) return;

        const children = Array.from(popup.children);
        for (let i = 0; i < children.length; i++) {
            const child = children[i];
            if (!child.classList.contains('more-popup-section-title')) continue;

            // Find the divider before this title
            const dividerBefore = i > 0 && children[i - 1].classList.contains('more-popup-divider')
                ? children[i - 1] : null;

            // Collect all option buttons after this title until next divider/title/end
            const sectionItems = [];
            for (let j = i + 1; j < children.length; j++) {
                if (children[j].classList.contains('more-popup-divider') ||
                    children[j].classList.contains('more-popup-section-title') ||
                    children[j].classList.contains('more-popup-arrow')) break;
                if (children[j].classList.contains('more-option')) {
                    sectionItems.push(children[j]);
                }
            }

            // If all section items are hidden, hide the title and preceding divider
            const allHidden = sectionItems.length > 0 &&
                sectionItems.every(el => el.style.display === 'none');
            if (allHidden) {
                child.style.display = 'none';
                if (dividerBefore) dividerBefore.style.display = 'none';
            }
        }
    }

    // Hide toolbar dividers that are adjacent to each other (no visible items between them)
    cleanupToolbarDividers() {
        const center = document.querySelector('.toolbar-center');
        if (!center) return;

        const children = Array.from(center.children);
        let lastVisibleWasDivider = true;
        for (const child of children) {
            if (child.style.display === 'none') continue;
            const isDivider = child.classList.contains('toolbar-divider');
            if (isDivider && lastVisibleWasDivider) {
                child.style.display = 'none';
            } else {
                lastVisibleWasDivider = isDivider;
            }
        }
        // Hide trailing divider
        for (let i = children.length - 1; i >= 0; i--) {
            if (children[i].style.display === 'none') continue;
            if (children[i].classList.contains('toolbar-divider')) {
                children[i].style.display = 'none';
            }
            break;
        }
    }

    // Adjust UI for image-based flipbooks (disable PDF-specific features).
    // When CONFIG.pdfSource is ALSO present (cloud emits both for fast
    // image rendering + PDF fallback), keep search/download/form/readAloud
    // enabled — they have a real PDF to operate on.
    setupImageBasedUI() {
        const hasPdfFallback = !!CONFIG.pdfSource;

        // Disable search button only when there's no PDF text source.
        if (!hasPdfFallback) {
            const searchBtn = document.getElementById('searchBtn');
            if (searchBtn) {
                searchBtn.classList.add('disabled');
                searchBtn.title = t('search.notAvailable');
            }

            const downloadBtn = document.getElementById('downloadBtn');
            if (downloadBtn) {
                downloadBtn.classList.add('disabled');
                downloadBtn.title = t('notify.downloadNotAvailable');
            }

            const formViewerOption = document.getElementById('formViewerOption');
            if (formViewerOption) {
                formViewerOption.classList.add('disabled');
                formViewerOption.title = t('notify.formNotAvailable');
            }

            const readAloudBtn = document.getElementById('readAloudBtn');
            if (readAloudBtn) {
                readAloudBtn.classList.add('disabled');
                readAloudBtn.title = t('notify.readAloudNotAvailable');
            }
        }
    }

    async loadPDF() {
        const progressBar = document.getElementById('progressBar');
        const progressText = document.getElementById('progressText');
        const loadingTitle = document.querySelector('.loading-title');

        // Creative loading messages
        const loadingPhrases = [
            'Brewing fresh pages...',
            'Folding paper magic...',
            'Sprinkling pixel dust...',
            'Waking up the pages...',
            'Stretching the canvas...',
            'Painting with light...',
            'Summoning words...',
            'Stitching stories...',
            'Polishing chapters...',
            'Fluffing the pages...',
            'Whisking up visuals...',
            'Crafting your read...'
        ];

        const titlePhrases = [
            'Just a moment...',
            'Almost there...',
            'Hang tight...',
            'Good things coming...',
            'Worth the wait...'
        ];

        let phraseIndex = 0;
        const getRandomPhrase = () => loadingPhrases[Math.floor(Math.random() * loadingPhrases.length)];
        const getRandomTitle = () => titlePhrases[Math.floor(Math.random() * titlePhrases.length)];

        loadingTitle.textContent = t('loading.preparing');
        progressText.textContent = t('loading.fetching');
        progressBar.style.width = '5%';

        // Initialize PDF storage
        this.pdfDocs = [];        // Array of loaded PDF documents
        this.pdfSections = [];    // Track which pages belong to which PDF
        this.numPages = 0;

        // Determine if we're loading multiple PDFs or a single PDF
        const pdfSources = CONFIG.pdfSources || (CONFIG.pdfSource ? [{ src: CONFIG.pdfSource, name: 'Document' }] : []);
        const totalPdfs = pdfSources.length;

        if (totalPdfs === 0) {
            throw new Error('No PDF source configured. Please upload a PDF in the flipbook settings.');
        }

        // Load all PDFs in parallel for faster startup
        if (totalPdfs > 1) {
            loadingTitle.textContent = t('loading.documents');
        }

        // Check content protection settings
        const cp = CONFIG.contentProtection;
        const hideSourcePaths = cp?.enabled && cp?.hideSourcePaths;
        const proxyEndpoint = cp?.enabled && cp?.proxyEndpoint;

        const loadPromises = pdfSources.map(async (pdfInfo) => {
            let pdfSrc = typeof pdfInfo === 'string' ? pdfInfo : pdfInfo.src;

            // If proxy endpoint is configured, transform the URL
            // The pdfSrc becomes the document ID, proxy handles actual file mapping
            if (proxyEndpoint) {
                // Extract just the filename/ID from the path if it's a full path
                const docId = pdfSrc.split('/').pop().replace(/\.[^/.]+$/, ''); // Remove extension
                pdfSrc = proxyEndpoint.replace('{id}', encodeURIComponent(docId));
            }

            // Note: hideSourcePaths previously buffered the full PDF into a blob URL,
            // which defeats PDF.js HTTP range streaming. The proxyEndpoint (when enabled)
            // already obfuscates the underlying file path, so we pass the URL through
            // directly and let PDF.js stream byte ranges as needed.
            const loadingTask = pdfjsLib.getDocument({
                url: pdfSrc,
                standardFontDataUrl: CONFIG._standardFontDataUrl || './pdfjs-5.4.530-legacy-dist/web/standard_fonts/',
                cMapUrl: CONFIG._cMapUrl || './pdfjs-5.4.530-legacy-dist/web/cmaps/',
                cMapPacked: true,
                // PDF.js 5.x decodes JPEG 2000 (JPXDecode) images and does CMYK/ICC
                // color management via WASM (OpenJPEG + qcms). Without wasmUrl those
                // images silently fail to decode and render blank — common in
                // print/InDesign PDFs. Point at the bundled wasm/ dir.
                wasmUrl: CONFIG._wasmUrl || './pdfjs-5.4.530-legacy-dist/wasm/',
                verbosity: 0,
            });

            // Handle password-protected PDFs
            loadingTask.onPassword = (updatePassword, reason) => {
                // Pro: admin pre-filled password - use it automatically on first attempt
                if (CONFIG.pdfPassword && reason === 1 /* NEED_PASSWORD */) {
                    const pw = CONFIG.pdfPassword;
                    CONFIG.pdfPassword = null; // Only try once
                    updatePassword(pw);
                    return;
                }
                // Show password prompt to reader
                this.showPasswordPrompt(updatePassword, reason);
            };

            return loadingTask.promise;
        });

        this.pdfDocs = await Promise.all(loadPromises);

        // Build section info sequentially (just arithmetic, order matters)
        for (let pdfIndex = 0; pdfIndex < totalPdfs; pdfIndex++) {
            const pdfInfo = pdfSources[pdfIndex];
            const pdfSrc = typeof pdfInfo === 'string' ? pdfInfo : pdfInfo.src;
            const pdfName = typeof pdfInfo === 'string' ? `Document ${pdfIndex + 1}` : (pdfInfo.name || `Document ${pdfIndex + 1}`);
            const pdfDoc = this.pdfDocs[pdfIndex];

            // Track section info
            const sectionStart = this.numPages + 1;
            const sectionEnd = this.numPages + pdfDoc.numPages;
            this.pdfSections.push({
                name: pdfName,
                // Don't store original path if hideSourcePaths is enabled
                src: hideSourcePaths ? `document_${pdfIndex + 1}` : pdfSrc,
                startPage: sectionStart,
                endPage: sectionEnd,
                pageCount: pdfDoc.numPages,
                pdfIndex: pdfIndex
            });

            this.numPages += pdfDoc.numPages;
        }

        // For backwards compatibility, set pdfDoc to first document
        this.pdfDoc = this.pdfDocs[0];
        this.totalSpreads = Math.ceil(this.numPages / 2);

        // Extract PDF outline (table of contents)
        this.pdfOutline = [];
        for (let i = 0; i < this.pdfDocs.length; i++) {
            const outline = await this.pdfDocs[i].getOutline();
            if (outline) {
                const pageOffset = i > 0 ? this.pdfSections[i].startPage - 1 : 0;
                const resolved = await this.resolveOutline(outline, this.pdfDocs[i], pageOffset);
                this.pdfOutline.push(...resolved);
            }
        }

        // Merge custom table of contents from config (supplements or replaces PDF outline)
        if (Array.isArray(CONFIG.tableOfContents) && CONFIG.tableOfContents.length > 0) {
            const customEntries = CONFIG.tableOfContents
                .filter(e => e && e.title && e.page)
                .map(e => ({ title: String(e.title), page: parseInt(e.page, 10), children: [] }));
            if (customEntries.length > 0) {
                // If no PDF outline exists, use custom entries as the full outline
                // If PDF outline exists, append custom entries under a "Custom" section
                if (this.pdfOutline.length === 0) {
                    this.pdfOutline = customEntries;
                } else {
                    this.pdfOutline.push(...customEntries);
                }
            }
        }

        progressBar.style.width = '10%';
        progressText.textContent = getRandomPhrase();

        // Calculate page dimensions from first page (upright, rotation-normalised
        // so the geometry aspect matches what processPage actually renders)
        const firstPage = await this.getPdfPage(1);
        const firstRotate = (((firstPage.rotate || 0) % 360) + 360) % 360;
        const viewport = firstPage.getViewport({ scale: 1, rotation: firstRotate });
        const aspectRatio = viewport.width / viewport.height;
        CONFIG.pageHeight = CONFIG.pageWidth / aspectRatio;

        // Notify parent frame of ideal aspect ratio for adaptive sizing
        if (window.parent !== window) {
            try {
                // Spread shows ~2 pages side by side; add vertical space for toolbar + padding
                const spreadRatio = (2 * CONFIG.pageWidth) / (CONFIG.pageHeight + 1.5);
                const displayRatio = Math.max(1, Math.min(spreadRatio, 2.5)); // clamp to sane range
                const postId = CONFIG._postId || 0;
                window.parent.postMessage({
                    type: 'tncfb-resize',
                    id: postId,
                    aspectRatio: displayRatio.toFixed(4),
                }, '*');
            } catch (e) { /* cross-origin — ignored */ }
        }

        // Pre-allocate arrays for all pages
        this.pageTextures = new Array(this.numPages).fill(null);
        this.pageCanvases = new Array(this.numPages).fill(null);
        this.pageTextContent = new Array(this.numPages).fill('');
        this.pageAnnotations = new Array(this.numPages).fill(null);
        this.pageMediaAnnotations = new Array(this.numPages).fill(null);
        this.pageDataURLs = new Array(this.numPages).fill(null);

        // Process initial pages (first 4 or fewer) for fast startup — in parallel
        const initialCount = Math.min(4, this.numPages);
        let initialDone = 0;
        const initialPromises = [];
        for (let i = 1; i <= initialCount; i++) {
            initialPromises.push(
                this.processPage(i).then((result) => {
                    this.storePageResult(result);
                    initialDone++;
                    const progress = 10 + (initialDone / initialCount) * 20;
                    progressBar.style.width = `${progress}%`;
                    if (initialDone % 2 === 0) progressText.textContent = getRandomPhrase();
                })
            );
        }
        await Promise.all(initialPromises);

        loadingTitle.textContent = getRandomTitle();
        progressBar.style.width = '35%';

        // Background loading will be triggered after createBook()
        this._backgroundLoadStart = initialCount + 1;
    }

    // Load remaining pages in background after flipbook is shown
    async loadRemainingPages() {
        if (!this._backgroundLoadStart || this._backgroundLoadStart > this.numPages) {
            this.backgroundLoadComplete = true;
            return;
        }

        const BATCH_SIZE = (CONFIG.memoryOptimized !== false)
            ? (CONFIG.bgLoadBatchSize || 2)
            : 4;
        const startPage = this._backgroundLoadStart;
        const pageIndicator = document.getElementById('pageIndicator');

        for (let start = startPage; start <= this.numPages; start += BATCH_SIZE) {
            // Bail out of eager background loading if heap pressure is high.
            // On-demand rendering still works; we just stop pre-loading.
            if (CONFIG.memoryOptimized !== false && performance && performance.memory) {
                const used = performance.memory.usedJSHeapSize || 0;
                const limit = performance.memory.jsHeapSizeLimit || 0;
                if (limit > 0 && used / limit > 0.85) {
                    break;
                }
            }

            const end = Math.min(start + BATCH_SIZE - 1, this.numPages);
            const promises = [];
            for (let i = start; i <= end; i++) {
                promises.push(this.processPage(i));
            }
            const results = await Promise.all(promises);
            for (const result of results) {
                this.storePageResult(result);
                this.updatePageTexture(result.pageNum);
            }

            // Update subtle loading indicator
            if (pageIndicator && !this.backgroundLoadComplete) {
                const loaded = end;
                const total = this.numPages;
                if (loaded < total) {
                    pageIndicator.dataset.loading = 'true';
                }
            }

            // Yield to the event loop between batches so input/UI stays responsive
            // and GC has a chance to run between allocations.
            if (CONFIG.memoryOptimized !== false) {
                await new Promise(r => setTimeout(r, 0));
            }
        }

        this.backgroundLoadComplete = true;
        if (pageIndicator) {
            delete pageIndicator.dataset.loading;
        }

        // Free GPU memory for pages outside the active window (large books only)
        if (typeof this._updateTextureWindow === 'function') {
            try { this._updateTextureWindow(); } catch (e) { /* defensive */ }
        }

        // Refresh thumbnails with all pages loaded
        this.createThumbnails();
    }

    // Update a page's texture after background loading
    updatePageTexture(pageNum) {
        if (!this.pages) return;
        if (this.is2D) {
            this._2d_updatePageCanvas(pageNum);
            return;
        }
        for (const pageData of this.pages) {
            if (pageData.frontPageNum === pageNum && pageData.frontMaterial) {
                pageData.frontMaterial.map = this.pageTextures[pageNum - 1];
                pageData.frontMaterial.needsUpdate = true;
            }
            if (pageData.backPageNum === pageNum && pageData.backMaterial) {
                // The back mesh mirrors via its geometry UVs now (see createPage),
                // so the texture is assigned as-is — no repeat.x mutation, which
                // used to leak the mirror onto front faces that reused the texture.
                pageData.backMaterial.map = this.pageTextures[pageNum - 1];
                pageData.backMaterial.needsUpdate = true;
            }
        }
    }

    // ── GPU texture window (LRU) ─────────────────────────────────────────────
    // For large books we keep canvases in RAM but only keep textures uploaded
    // to the GPU for pages near the current page. Textures for distant pages
    // are disposed and recreated on demand from the cached canvas.

    _shouldUseTextureWindow() {
        if (this.is2D || this.isImageBased) return false; // images already store final textures; 2D has no GPU textures
        const opt = CONFIG.lazyTextureWindow;
        const threshold = (typeof CONFIG.lazyTextureThreshold === 'number') ? CONFIG.lazyTextureThreshold : 30;
        if (opt === false) return false;
        if (opt === true) return true;
        return this.numPages > threshold; // auto: only on large books
    }

    // Heuristic for "this device will OOM on large page-canvas sets" — chiefly
    // iOS Safari, which silently reloads/kills tabs over a per-tab memory ceiling.
    // Cached after first call. Used to tighten the render pixel budget.
    _isLowMemoryDevice() {
        if (this._lowMemDevice !== undefined) return this._lowMemDevice;
        let low = false;
        try {
            const nav = (typeof navigator !== 'undefined') ? navigator : {};
            const ua = nav.userAgent || '';
            const plat = nav.platform || '';
            const touchPoints = nav.maxTouchPoints || 0;
            // iPhone/iPod/iPad (incl. iPadOS reporting as desktop Safari).
            const isIOS = /iP(hone|od|ad)/.test(ua) || /iP(hone|od|ad)/.test(plat) ||
                          (/Mac/.test(plat) && touchPoints > 1);
            // Phone-sized touch screen (catches Android phones without over-
            // capping large, capable tablets).
            const smallViewport = Math.min(window.innerWidth || 9999, window.innerHeight || 9999) < 820;
            const touchPhone = touchPoints > 0 && smallViewport;
            const lowRam = typeof nav.deviceMemory === 'number' && nav.deviceMemory > 0 && nav.deviceMemory <= 4;
            low = isIOS || touchPhone || lowRam;
        } catch (e) { /* keep default */ }
        this._lowMemDevice = low;
        return low;
    }

    _ensurePageTexture(pageNum) {
        const idx = pageNum - 1;
        if (idx < 0 || idx >= this.numPages) return;
        if (this.pageTextures[idx]) return; // already on GPU
        const canvas = this.pageCanvases && this.pageCanvases[idx];
        if (!canvas || !THREE) return;

        const tex = new THREE.CanvasTexture(canvas);
        tex.colorSpace = THREE.SRGBColorSpace;
        tex.needsUpdate = true;
        this.pageTextures[idx] = tex;

        // Reattach to any meshes referencing this page
        if (this.pages) {
            for (const pageData of this.pages) {
                if (pageData.frontPageNum === pageNum && pageData.frontMaterial) {
                    pageData.frontMaterial.map = tex;
                    pageData.frontMaterial.needsUpdate = true;
                }
                if (pageData.backPageNum === pageNum && pageData.backMaterial) {
                    pageData.backMaterial.map = tex;
                    pageData.backMaterial.needsUpdate = true;
                }
            }
        }
    }

    _releasePageTexture(pageNum) {
        const idx = pageNum - 1;
        if (idx < 0 || idx >= this.numPages) return;
        const tex = this.pageTextures[idx];
        if (!tex) return;

        // Detach from materials before disposing so we don't leave a dangling reference
        if (this.pages) {
            for (const pageData of this.pages) {
                if (pageData.frontPageNum === pageNum && pageData.frontMaterial && pageData.frontMaterial.map === tex) {
                    pageData.frontMaterial.map = null;
                    pageData.frontMaterial.needsUpdate = true;
                }
                if (pageData.backPageNum === pageNum && pageData.backMaterial && pageData.backMaterial.map === tex) {
                    pageData.backMaterial.map = null;
                    pageData.backMaterial.needsUpdate = true;
                }
            }
        }
        try { tex.dispose(); } catch (e) { /* ignore */ }
        this.pageTextures[idx] = null;
    }

    _updateTextureWindow(currentPage) {
        if (!this._shouldUseTextureWindow()) return;
        if (!this.pageTextures || !this.pageCanvases) return;

        const windowSize = (typeof CONFIG.textureWindowSize === 'number') ? CONFIG.textureWindowSize : 10;
        const cur = currentPage || (typeof this.getCurrentPageNumber === 'function' ? this.getCurrentPageNumber() : 1);

        for (let i = 1; i <= this.numPages; i++) {
            const dist = Math.abs(i - cur);
            if (dist <= windowSize) {
                this._ensurePageTexture(i);
            } else {
                this._releasePageTexture(i);
            }
        }
    }

    // Helper: Get the correct PDF document and local page number for a global page number
    getPdfForPage(globalPageNum) {
        // For image-based flipbooks, there are no PDF documents
        if (this.isImageBased) {
            return {
                pdfDoc: null,
                localPageNum: globalPageNum,
                section: this.pdfSections[0] || null
            };
        }

        for (const section of this.pdfSections) {
            if (globalPageNum >= section.startPage && globalPageNum <= section.endPage) {
                const localPageNum = globalPageNum - section.startPage + 1;
                return {
                    pdfDoc: this.pdfDocs[section.pdfIndex],
                    localPageNum: localPageNum,
                    section: section
                };
            }
        }
        // Fallback to first PDF
        return {
            pdfDoc: this.pdfDocs[0],
            localPageNum: globalPageNum,
            section: this.pdfSections[0]
        };
    }

    // Helper: Get a page from the correct PDF document
    async getPdfPage(globalPageNum) {
        // For image-based flipbooks, return null (no PDF pages)
        if (this.isImageBased) {
            return null;
        }
        const { pdfDoc, localPageNum } = this.getPdfForPage(globalPageNum);
        return await pdfDoc.getPage(localPageNum);
    }

    // Get section info for a page (useful for UI)
    getPageSection(globalPageNum) {
        for (const section of this.pdfSections) {
            if (globalPageNum >= section.startPage && globalPageNum <= section.endPage) {
                return section;
            }
        }
        return this.pdfSections[0];
    }

    // Navigate to a specific PDF section by index or name
    goToSection(sectionIndexOrName) {
        if (!this.pdfSections || this.pdfSections.length === 0) return;

        let section;
        if (typeof sectionIndexOrName === 'number') {
            section = this.pdfSections[sectionIndexOrName];
        } else {
            section = this.pdfSections.find(s => s.name === sectionIndexOrName);
        }

        if (section) {
            this.goToPage(section.startPage);
        }
    }

    // Get all sections (useful for building a table of contents)
    getSections() {
        return this.pdfSections || [];
    }

    // Process a single page: render texture, extract text, extract annotations
    async processPage(pageNum) {
        const page = await this.getPdfPage(pageNum);
        // Render the page upright regardless of how rotation is encoded in the
        // source. PDF.js bakes the page's intrinsic /Rotate into the viewport by
        // default; we normalise it explicitly (handles negative / non-multiple
        // values) so a malformed flag can't yield a sideways or upside-down
        // texture. Mixed per-page rotation also conflicts with the single
        // page-1 aspect the book geometry uses, so we surface it once.
        const pageRotate = (((page.rotate || 0) % 360) + 360) % 360;
        if (pageRotate !== 0 && !this._warnedRotatedPages) {
            this._warnedRotatedPages = true;
            console.warn(`[TNCFlipBook] PDF has pages with intrinsic rotation (page ${pageNum}: ${pageRotate}°). Rendering upright; if a page still looks upside-down or sideways, the source PDF likely carries a conflicting /Rotate flag.`);
        }
        const viewport = page.getViewport({ scale: 1, rotation: pageRotate });

        // Render texture
        let renderScale = CONFIG.renderScale;
        if (CONFIG.memoryOptimized !== false) {
            let maxEdge = CONFIG.maxRenderPixels || 2048;
            // Memory-constrained devices (notably iOS Safari, which reloads/kills
            // tabs that exceed a per-tab memory ceiling) get a tighter pixel budget
            // so the retained page canvases don't OOM on long PDFs. A phone screen
            // at ~1280px is already near its native resolution, so quality holds.
            if (this._isLowMemoryDevice()) {
                maxEdge = Math.min(maxEdge, CONFIG.maxRenderPixelsMobile || 1280);
            }
            const longestAt1 = Math.max(viewport.width, viewport.height);
            if (longestAt1 > 0) {
                const scaleCap = maxEdge / longestAt1;
                if (renderScale > scaleCap) renderScale = scaleCap;
            }
        }
        const renderViewport = page.getViewport({ scale: renderScale, rotation: pageRotate });
        const canvas = document.createElement('canvas');
        canvas.width = renderViewport.width;
        canvas.height = renderViewport.height;
        const ctx = canvas.getContext('2d', { alpha: false });
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        const renderParams = { canvasContext: ctx, viewport: renderViewport };
        // Disable annotation rendering on canvas when pdfLinkOverlay is off
        if (CONFIG.pdfLinkOverlay === false) {
            renderParams.annotationMode = 0; // DISABLE = 0
        }
        await page.render(renderParams).promise;

        let texture = null;
        if (!this.is2D && THREE) {
            texture = new THREE.CanvasTexture(canvas);
            texture.colorSpace = THREE.SRGBColorSpace;
            texture.needsUpdate = true;
        }

        // Extract text (search string only — text selection items deferred)
        const textContent = await page.getTextContent();
        const text = textContent.items.map(item => item.str).join(' ');

        // Extract annotations (links + media in single pass)
        const annotations = await page.getAnnotations();
        const { links, media } = this.parseAnnotations(annotations, viewport, pageNum);

        return { pageNum, texture, canvas, text, links, media };
    }

    // Store results from processPage into instance arrays
    storePageResult(result) {
        const idx = result.pageNum - 1;
        this.pageTextures[idx] = result.texture;
        this.pageCanvases[idx] = result.canvas;
        this.pageTextContent[idx] = result.text;
        this.pageAnnotations[idx] = result.links;
        this.pageMediaAnnotations[idx] = result.media;
    }

    // On-demand priority render for a specific page (bypasses bg-load throttle).
    // Dedupes concurrent calls for the same page via _pendingRenders.
    // Returns a Promise that resolves once the page texture is available.
    ensurePageRendered(pageNum) {
        if (!this.numPages || pageNum < 1 || pageNum > this.numPages) {
            return Promise.resolve();
        }
        const idx = pageNum - 1;
        if (this.pageCanvases && this.pageCanvases[idx]) {
            return Promise.resolve();
        }
        if (!this._pendingRenders) this._pendingRenders = new Map();
        const existing = this._pendingRenders.get(pageNum);
        if (existing) return existing;

        const p = (async () => {
            try {
                const result = await this.processPage(pageNum);
                this.storePageResult(result);
                this.updatePageTexture(pageNum);
            } catch (e) {
                // Silent — the user can retry via nav; bg loader will eventually fill it.
            } finally {
                if (this._pendingRenders) this._pendingRenders.delete(pageNum);
            }
        })();
        this._pendingRenders.set(pageNum, p);
        return p;
    }

    // Compute the page numbers that will be visible at the given flipped state.
    // Used to prefetch the right pages before a jump.
    _getJumpTargetPages(targetFlipped) {
        if (!this.numPages) return [];
        const pages = [];
        if (this.forceSinglePage) {
            pages.push(this.currentSinglePage || 1);
        } else if (this.isEvenPageMode) {
            const left  = targetFlipped * 2 - 1;
            const right = targetFlipped * 2;
            if (left  >= 1 && left  <= this.numPages) pages.push(left);
            if (right >= 1 && right <= this.numPages) pages.push(right);
        } else {
            if (targetFlipped <= 0) {
                pages.push(1);
            } else if (targetFlipped >= this.pages.length) {
                pages.push(this.numPages);
            } else {
                const left  = targetFlipped * 2;
                const right = targetFlipped * 2 + 1;
                if (left  >= 1 && left  <= this.numPages) pages.push(left);
                if (right >= 1 && right <= this.numPages) pages.push(right);
            }
        }
        return pages;
    }

    // Kick off a priority prefetch for the target spread + its immediate neighbors,
    // and show a spinner if any target page is not yet rendered.
    // Non-blocking — animation can start immediately.
    _prefetchJumpTargets(targetFlipped) {
        if (this.isImageBased) return; // images already have textures
        const targets = this._getJumpTargetPages(targetFlipped);
        if (!targets.length) return;

        const needs = targets.filter(n => !(this.pageCanvases && this.pageCanvases[n - 1]));
        if (!needs.length) return;

        this._showJumpSpinner();
        const promises = needs.map(n => this.ensurePageRendered(n));
        // Also prefetch nearest neighbors in the background (no spinner gating).
        for (const n of targets) {
            if (n - 1 >= 1) this.ensurePageRendered(n - 1);
            if (n + 1 <= this.numPages) this.ensurePageRendered(n + 1);
        }
        Promise.all(promises).then(() => this._hideJumpSpinner());
    }

    _showJumpSpinner() {
        if (this._jumpSpinnerEl) {
            this._jumpSpinnerEl.classList.add('is-visible');
            return;
        }
        const container = document.getElementById('canvas-container');
        if (!container) return;
        const el = document.createElement('div');
        el.className = 'tncfb-jump-spinner';
        el.setAttribute('aria-hidden', 'true');
        el.innerHTML = '<svg viewBox="0 0 50 50"><circle cx="25" cy="25" r="20" /></svg>';
        container.appendChild(el);
        this._jumpSpinnerEl = el;
        // Force layout so the fade-in transition runs.
        // eslint-disable-next-line no-unused-expressions
        el.offsetWidth;
        el.classList.add('is-visible');
    }

    _hideJumpSpinner() {
        if (this._jumpSpinnerEl) {
            this._jumpSpinnerEl.classList.remove('is-visible');
        }
    }

    // Lazy data URL generation (deferred from render time)
    // idx is 0-based page index
    getPageDataURL(idx) {
        if (!this.pageDataURLs[idx] && this.pageCanvases[idx]) {
            this.pageDataURLs[idx] = this.pageCanvases[idx].toDataURL('image/jpeg', 0.8);
        }
        return this.pageDataURLs[idx] || '';
    }


    // Parse annotations from a single getAnnotations() call into links and media
    parseAnnotations(annotations, viewport, pageNum) {
        const links = [];
        const media = [];

        for (const annot of annotations) {
            const subtype = annot.subtype;

            // --- Link / Button annotations ---
            const isLink = subtype === 'Link';
            const isButton = subtype === 'Widget' && annot.fieldType === 'Btn' && annot.pushButton;

            if (isLink || isButton) {
                const rect = annot.rect;
                if (!rect || rect.length < 4) continue;

                const link = {
                    x: rect[0] / viewport.width,
                    y: 1 - rect[3] / viewport.height,
                    w: (rect[2] - rect[0]) / viewport.width,
                    h: (rect[3] - rect[1]) / viewport.height,
                    sourcePage: pageNum,
                };

                let resolved = false;
                if (annot.url) {
                    link.type = 'external';
                    link.url = annot.url;
                    resolved = true;
                } else if (annot.dest) {
                    link.type = 'internal';
                    link.dest = annot.dest;
                    resolved = true;
                } else if (annot.action) {
                    if (annot.action.dest) {
                        link.type = 'internal';
                        link.dest = annot.action.dest;
                        resolved = true;
                    } else if (annot.action.url) {
                        link.type = 'external';
                        link.url = annot.action.url;
                        resolved = true;
                    }
                } else if (annot.unsafeUrl) {
                    link.type = 'external';
                    link.url = annot.unsafeUrl;
                    resolved = true;
                }

                if (!resolved && isButton && annot.actions) {
                    const jsAction = annot.actions.MouseUp || annot.actions.Action;
                    if (jsAction) {
                        const jsStr = Array.isArray(jsAction) ? jsAction.join('') : String(jsAction);
                        const pageMatch = jsStr.match(/(?:pageNum\s*=\s*|goToPage\s*\(\s*)(\d+)/i);
                        if (pageMatch) {
                            link.type = 'internal';
                            link.dest = parseInt(pageMatch[1], 10);
                            resolved = true;
                        }
                    }
                }

                if (resolved) links.push(link);
                continue;
            }

            // --- Media annotations ---
            if (['RichMedia', 'Screen', 'Movie', '3D'].includes(subtype)) {
                const rect = annot.rect;
                if (!rect || rect.length < 4) continue;

                const mediaAnnot = {
                    id: annot.id || `media-${pageNum}-${media.length}`,
                    subtype,
                    pageNum,
                    x: rect[0] / viewport.width,
                    y: 1 - (rect[3] / viewport.height),
                    w: (rect[2] - rect[0]) / viewport.width,
                    h: (rect[3] - rect[1]) / viewport.height,
                    rect: annot.rect
                };

                if (subtype === 'Movie' || subtype === 'Screen') {
                    Object.assign(mediaAnnot, this._extractMediaInfo(annot));
                } else if (subtype === '3D') {
                    Object.assign(mediaAnnot, this._extract3DInfo(annot));
                } else if (subtype === 'RichMedia') {
                    Object.assign(mediaAnnot, this._extractRichMediaInfo(annot));
                }

                media.push(mediaAnnot);
            }
        }

        return { links, media };
    }

    _extractMediaInfo(annot) {
        const info = { mediaType: 'video', url: null, data: null, mimeType: null };
        const url = annot.url || (annot.file && annot.file.url) || null;
        if (url) {
            info.url = url;
            const ext = url.split('.').pop().toLowerCase();
            if (['mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'].includes(ext)) {
                info.mediaType = 'audio';
            }
            const mimeMap = { mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg' };
            info.mimeType = mimeMap[ext] || null;
        }
        if (annot.file && annot.file.content) {
            try {
                const blob = new Blob([annot.file.content], { type: info.mimeType || 'video/mp4' });
                info.data = blob;
            } catch (e) { /* embedded data not accessible */ }
        }
        return info;
    }

    _extract3DInfo(annot) {
        const info = { format: null, url: null, data: null };
        const url = annot.url || (annot.file && annot.file.url) || null;
        if (url) {
            info.url = url;
            const ext = url.split('.').pop().toLowerCase();
            if (ext === 'glb' || ext === 'gltf') info.format = 'gltf';
            else if (ext === 'u3d') info.format = 'u3d';
            else if (ext === 'prc') info.format = 'prc';
            else info.format = ext;
        }
        if (annot.file && annot.file.content) {
            try {
                const blob = new Blob([annot.file.content]);
                info.data = blob;
                if (!info.format) info.format = 'gltf';
            } catch (e) { /* embedded data not accessible */ }
        }
        if (!info.format) info.format = 'unknown';
        return info;
    }

    _extractRichMediaInfo(annot) {
        const info = { richMediaType: 'unknown', url: null, data: null, mimeType: null, activation: 'click' };
        if (annot.richMediaContent) {
            const rc = annot.richMediaContent;
            if (rc.assets && rc.assets.length > 0) {
                const asset = rc.assets[0];
                info.url = asset.url || asset.name || null;
                if (info.url) {
                    const ext = info.url.split('.').pop().toLowerCase();
                    if (['swf', 'flash'].includes(ext)) info.richMediaType = 'flash';
                    else if (['html', 'htm'].includes(ext)) info.richMediaType = 'html';
                    else if (['mp4', 'webm', 'ogv'].includes(ext)) info.richMediaType = 'video';
                    else if (['mp3', 'wav', 'ogg'].includes(ext)) info.richMediaType = 'audio';
                }
            }
            if (rc.configurations) {
                const cfg = rc.configurations[0];
                if (cfg && cfg.instances) {
                    const inst = cfg.instances[0];
                    if (inst && inst.params && inst.params.activation) {
                        info.activation = inst.params.activation;
                    }
                }
            }
        }
        return info;
    }

    async resolvePageDest(dest, sourcePageNum = 1) {
        // Resolve a PDF destination to a global page number
        // sourcePageNum tells us which PDF the link is from
        try {
            // Get the PDF document and section info for the source page
            const { pdfDoc, section } = this.getPdfForPage(sourcePageNum);
            const sectionOffset = section.startPage - 1; // Offset to convert local to global

            // dest can be a page number directly
            if (typeof dest === 'number') {
                return dest + 1 + sectionOffset; // PDF.js uses 0-indexed, add offset for global
            }

            let ref;
            if (typeof dest === 'string') {
                // Named destination - use the correct PDF document
                const destObj = await pdfDoc.getDestination(dest);
                if (!destObj) return null;
                ref = destObj[0];
            } else if (Array.isArray(dest)) {
                ref = dest[0];
            } else {
                return null;
            }

            // ref can be a page reference object or a page index number
            if (typeof ref === 'number') {
                return ref + 1 + sectionOffset; // 0-indexed to 1-indexed + offset
            }
            if (ref && typeof ref === 'object') {
                const pageIndex = await pdfDoc.getPageIndex(ref);
                return pageIndex + 1 + sectionOffset; // 1-indexed + offset for global
            }
            return null;
        } catch (e) {
            console.warn('Could not resolve destination:', dest, e);
            return null;
        }
    }


    init() {
        // Apply toolbar layout
        if (CONFIG.toolbarLayout === 'top-center') {
            document.body.classList.add('layout-top-center');
        } else if (CONFIG.toolbarLayout === 'bottom-fullwidth') {
            document.body.classList.add('layout-bottom-fullwidth');
        } else if (CONFIG.toolbarLayout === 'top-fullwidth') {
            document.body.classList.add('layout-top-fullwidth');
        }

        // 2D CSS fallback: skip Three.js, create DOM-based book
        if (this.is2D) {
            this._2d_init();
            return;
        }

        // Scene
        this.scene = new THREE.Scene();

        // Camera - using CONFIG settings
        const cameraFOV = CONFIG.cameraFOV || 45;
        const cameraDistance = CONFIG.cameraDistance || 10;
        const cameraAngleDeg = CONFIG.cameraAngle || 15;
        const cameraAngleRad = (cameraAngleDeg * Math.PI) / 180;

        this.camera = new THREE.PerspectiveCamera(
            cameraFOV,
            window.innerWidth / window.innerHeight,
            0.1,
            1000
        );
        // Position camera based on distance and angle
        const camY = Math.sin(cameraAngleRad) * cameraDistance;
        const camZ = Math.cos(cameraAngleRad) * cameraDistance;
        this.camera.position.set(0, camY, camZ);
        this.camera.lookAt(0, 0, 0);

        // Renderer
        const isTransparent = !!CONFIG.transparentBackground;
        this.renderer = new THREE.WebGLRenderer({
            antialias: true,
            alpha: isTransparent,
            premultipliedAlpha: false
        });
        this._isTransparentBg = isTransparent;

        // Parse page background color from config for Three.js.
        // Each chrome ships its own dark/light variant — read both polarities
        // from the active chrome.
        const _activeChrome = getActiveChrome();
        this.sceneBgDark  = (_activeChrome.dark  || _activeChrome).pageBg;
        this.sceneBgLight = (_activeChrome.light || _activeChrome).pageBg;
        const parseCssHex = (cssColor) => {
            const match = cssColor.match(/#([0-9a-fA-F]{6})/);
            return match ? parseInt(match[1], 16) : 0x0a0a12;
        };
        if (isTransparent) {
            this.renderer.setClearColor(this.isDarkTheme ? 0x000000 : 0xffffff, 0);
        } else {
            this.renderer.setClearColor(parseCssHex(this.isDarkTheme ? this.sceneBgDark : this.sceneBgLight), 1);
        }
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        // Shadow settings from CONFIG
        const shadowsEnabled = CONFIG.shadowsEnabled !== false;
        this.renderer.shadowMap.enabled = shadowsEnabled;
        const shadowTypeMap = {
            'sharp': THREE.BasicShadowMap,
            'soft': THREE.PCFSoftShadowMap,
            'very-soft': THREE.VSMShadowMap
        };
        this.renderer.shadowMap.type = shadowTypeMap[CONFIG.shadowSoftness] || THREE.PCFSoftShadowMap;
        this.renderer.outputColorSpace = THREE.SRGBColorSpace;
        this.container.appendChild(this.renderer.domElement);

        // Lighting - using CONFIG settings
        const ambientIntensity = CONFIG.ambientLightIntensity ?? 1.2;
        const ambientLight = new THREE.AmbientLight(0xffffff, ambientIntensity);
        this.scene.add(ambientLight);
        this.ambientLight = ambientLight;

        const mainLightIntensity = CONFIG.mainLightIntensity ?? 0.5;
        const mainLightPos = CONFIG.mainLightPosition || [0, 10, 10];
        const mainLight = new THREE.DirectionalLight(0xffffff, mainLightIntensity);
        mainLight.position.set(mainLightPos[0], mainLightPos[1], mainLightPos[2]);
        mainLight.castShadow = shadowsEnabled;
        mainLight.shadow.mapSize.width = 2048;
        mainLight.shadow.mapSize.height = 2048;
        this.scene.add(mainLight);
        this.mainLight = mainLight;

        const fillLight = new THREE.DirectionalLight(0xffffff, 0.4);
        fillLight.position.set(0, 5, 5);
        this.scene.add(fillLight);

        const backLight = new THREE.DirectionalLight(0xffffff, 0.3);
        backLight.position.set(0, 0, -5);
        this.scene.add(backLight);


        // Handle resize
        window.addEventListener('resize', () => this.onResize());

    }

    initAudio() {
        const soundId = CONFIG.flipSound || '1';
        if (soundId === 'none') return;

        try {
            let audioUrl;
            if (soundId === 'custom' && CONFIG.flipSoundUrl) {
                audioUrl = CONFIG.flipSoundUrl;
            } else {
                const baseUrl = import.meta.url.substring(0, import.meta.url.lastIndexOf('/'));
                audioUrl = `${baseUrl}/flip-audio-${soundId}.mp3`;
            }
            this.flipAudio = new Audio(audioUrl);
            this.flipAudio.preload = 'auto';
        } catch (e) {
            this.flipAudio = null;
        }
    }

    playFlipSound() {
        if (!this.soundEnabled || !this.flipAudio) return;

        try {
            const sound = this.flipAudio.cloneNode();
            sound.volume = 0.5;
            sound.play();
        } catch (e) {
            // Silent fail
        }
    }

    toggleSound() {
        this.soundEnabled = !this.soundEnabled;
        analytics.trackSound(this.soundEnabled);
        // Update the More menu sound option display
        this.updateSoundOptionDisplay();
    }

    updatePeelOverlays() {
        // Update peel overlay positions to match book on screen
        this.updatePeelPositions();
        // Reposition link overlay to track page positions
        this.repositionLinkOverlay();
        // Reposition media overlay to track page positions
        this.repositionMediaOverlay();
    }


    updatePeelPositions() {
        if (!this.book || !this.pages.length) return;

        // Don't reposition while a corner lift or drag is active — sub-pixel projection
        // jitter moves the container and causes mouseenter/mouseleave flicker
        if (this.hoveredPage || this.dragPending || this.isDragging) return;
        // Also skip if any page still has an active lift animation (e.g. animate-down after mouseleave)
        if (this.pages.some(p => p.liftAnimationId)) return;

        const container = document.getElementById('pageContainer');
        if (!container) return;

        // Get the screen bounds of the book
        const bookBounds = this.getBookScreenBounds();
        if (!bookBounds) return;

        // Determine if we're in a single-page view
        const flippedCount = this.pages.filter(p => p.flipped).length;
        const isSingleView = this.forceSinglePage || flippedCount === this.minFlipped || flippedCount === this.pages.length;

        if (isSingleView) {
            // Use visible page bounds for accurate peel positioning
            const visiblePageBounds = this.getVisiblePageScreenBounds();
            if (visiblePageBounds) {
                container.style.width = visiblePageBounds.width + 'px';
                container.style.height = visiblePageBounds.height + 'px';
                container.style.left = visiblePageBounds.centerX + 'px';
                container.style.top = visiblePageBounds.centerY + 'px';
            }
        } else {
            // Size and position the peel container to match the full book
            container.style.width = bookBounds.width + 'px';
            container.style.height = bookBounds.height + 'px';
            container.style.left = bookBounds.centerX + 'px';
            container.style.top = bookBounds.centerY + 'px';
        }

        // Calculate current page numbers
        const isAtStart = flippedCount === this.minFlipped;
        const isAtEnd = flippedCount === this.pages.length;

        // Right corners show next page content
        const peelTopRight = document.getElementById('peelTopRight');
        const peelBottomRight = document.getElementById('peelBottomRight');
        // Left corners show previous page content
        const peelTopLeft = document.getElementById('peelTopLeft');
        const peelBottomLeft = document.getElementById('peelBottomLeft');

        let showRightPeels, showLeftPeels;

        if (this.forceSinglePage) {
            // Single page mode - show peels on BOTH sides for navigation
            const hasNext = this.currentSinglePage < this.numPages;
            const hasPrev = this.currentSinglePage > 1;

            if (this.isRTL) {
                showRightPeels = hasPrev;
                showLeftPeels = hasNext;
            } else {
                showRightPeels = hasNext;
                showLeftPeels = hasPrev;
            }
        } else {
            // Double page mode
            if (!isAtStart && !isAtEnd) {
                // Middle spreads: both sides have pages, both peels show
                showRightPeels = true;
                showLeftPeels = true;
            } else {
                // First/last spread: only one valid action.
                // Right click → 'next'; Left click → 'prev'
                const canGoNext = !isAtEnd;
                const canGoPrev = !isAtStart;

                if (this.isRTL) {
                    showRightPeels = canGoPrev;
                    showLeftPeels = canGoNext;
                } else {
                    showRightPeels = canGoNext;
                    showLeftPeels = canGoPrev;
                }
            }
        }

        peelTopRight.style.display = showRightPeels ? 'block' : 'none';
        peelBottomRight.style.display = showRightPeels ? 'block' : 'none';
        peelTopLeft.style.display = showLeftPeels ? 'block' : 'none';
        peelBottomLeft.style.display = showLeftPeels ? 'block' : 'none';

        // Set background images for peel folds
        // Determine page indices for peel fold images
        let rightPeelIdx, leftPeelIdx;

        if (this.forceSinglePage) {
            // Single page mode: both peels are on the same page
            const nextIdx = this.currentSinglePage; // 0-indexed
            const prevIdx = this.currentSinglePage - 2; // 0-indexed
            // RTL swaps which side shows next vs prev
            if (this.isRTL) {
                rightPeelIdx = prevIdx;
                leftPeelIdx = nextIdx;
            } else {
                rightPeelIdx = nextIdx;
                leftPeelIdx = prevIdx;
            }
        } else {
            // Double page mode
            let nextIdx, prevIdx;
            if (isAtStart) {
                nextIdx = 1;
                prevIdx = -1;
            } else if (isAtEnd) {
                nextIdx = this.numPages;
                prevIdx = this.numPages - 2;
            } else {
                // 0-indexed page indices for peel fold backgrounds
                if (this.isEvenPageMode) {
                    // Even mode: spread shows pages (flippedCount*2-1, flippedCount*2) 1-indexed
                    // Next page (0-indexed) = flippedCount*2 (page after right page)
                    // Prev page (0-indexed) = flippedCount*2-3 (page before left page)
                    nextIdx = flippedCount * 2;
                    prevIdx = flippedCount * 2 - 3;
                } else {
                    nextIdx = flippedCount * 2 + 1;
                    prevIdx = flippedCount * 2 - 1;
                }
            }

            if (isAtStart || isAtEnd) {
                // First/last spread: RTL swaps which peel shows which preview
                if (this.isRTL) {
                    rightPeelIdx = prevIdx;
                    leftPeelIdx = nextIdx;
                } else {
                    rightPeelIdx = nextIdx;
                    leftPeelIdx = prevIdx;
                }
            } else {
                // Middle spreads: right peel = next page, left peel = prev page (physical)
                rightPeelIdx = nextIdx;
                leftPeelIdx = prevIdx;
            }
        }

        if (showRightPeels && rightPeelIdx >= 0) {
            const url = this.getPageDataURL(rightPeelIdx);
            if (url) {
                peelTopRight.querySelector('.peel-fold').style.backgroundImage = `url(${url})`;
                peelBottomRight.querySelector('.peel-fold').style.backgroundImage = `url(${url})`;
            }
        }

        if (showLeftPeels && leftPeelIdx >= 0) {
            const url = this.getPageDataURL(leftPeelIdx);
            if (url) {
                peelTopLeft.querySelector('.peel-fold').style.backgroundImage = `url(${url})`;
                peelBottomLeft.querySelector('.peel-fold').style.backgroundImage = `url(${url})`;
            }
        }
    }

    // Get screen bounds for just the visible page in single-page view
    getVisiblePageScreenBounds() {
        if (!this.book) return null;

        const pageWidth = CONFIG.pageWidth;
        const pageHeight = CONFIG.pageHeight;

        // Determine which side the visible page is on
        let isRightPage;
        if (this.forceSinglePage) {
            isRightPage = this.currentSinglePage % 2 === 1;
        } else {
            // Double-page mode at first/last spread
            const flippedCount = this.pages.filter(p => p.flipped).length;
            isRightPage = flippedCount === this.minFlipped; // First spread = right page, last = left page
        }

        // Right page: local x 0 to pageWidth
        // Left page: local x -pageWidth to 0
        let corners;
        if (isRightPage) {
            corners = [
                new THREE.Vector3(0, pageHeight/2, 0),
                new THREE.Vector3(pageWidth, pageHeight/2, 0),
                new THREE.Vector3(0, -pageHeight/2, 0),
                new THREE.Vector3(pageWidth, -pageHeight/2, 0)
            ];
        } else {
            corners = [
                new THREE.Vector3(-pageWidth, pageHeight/2, 0),
                new THREE.Vector3(0, pageHeight/2, 0),
                new THREE.Vector3(-pageWidth, -pageHeight/2, 0),
                new THREE.Vector3(0, -pageHeight/2, 0)
            ];
        }

        // Transform by book's world matrix
        corners.forEach(corner => {
            corner.applyMatrix4(this.book.matrixWorld);
        });

        // Project to screen
        const screenCorners = corners.map(corner => {
            const projected = corner.clone().project(this.camera);
            return {
                x: (projected.x + 1) / 2 * window.innerWidth,
                y: (-projected.y + 1) / 2 * window.innerHeight
            };
        });

        // Get bounding box
        const minX = Math.min(...screenCorners.map(c => c.x));
        const maxX = Math.max(...screenCorners.map(c => c.x));
        const minY = Math.min(...screenCorners.map(c => c.y));
        const maxY = Math.max(...screenCorners.map(c => c.y));

        return {
            width: maxX - minX,
            height: maxY - minY,
            centerX: (minX + maxX) / 2,
            centerY: (minY + maxY) / 2
        };
    }

    getProjectedPageCorners() {
        if (!this.book || !this.camera) return null;
        const fc = this.pages.filter(p => p.flipped).length;
        const singleView = this.forceSinglePage || fc === this.minFlipped || fc === this.pages.length;

        const pw = CONFIG.pageWidth;
        const ph = CONFIG.pageHeight;
        let xLeft, xRight;
        if (singleView) {
            // When the ghost flipper is active, the visible page is ALWAYS
            // hinged at the spine extending right (xLeft=0, xRight=pw) —
            // regardless of whether the current logical page is odd/even.
            // Without this clamp the corner hit-test would project onto
            // the natural odd/even side, causing edge clicks to flip the
            // wrong direction on even pages.
            const flipperActive = this.forceSinglePage && this._singlePageFlipperReady;
            const isRightPage = flipperActive
                ? true
                : (this.forceSinglePage
                    ? (this.currentSinglePage % 2 === 1)
                    : (fc === this.minFlipped));
            xLeft = isRightPage ? 0 : -pw;
            xRight = isRightPage ? pw : 0;
        } else {
            xLeft = -pw;
            xRight = pw;
        }

        const project = (vec) => {
            const v = vec.clone().applyMatrix4(this.book.matrixWorld).project(this.camera);
            return {
                x: (v.x + 1) / 2 * window.innerWidth,
                y: (-v.y + 1) / 2 * window.innerHeight
            };
        };

        return {
            'top-left':     project(new THREE.Vector3(xLeft,  ph / 2, 0)),
            'top-right':    project(new THREE.Vector3(xRight, ph / 2, 0)),
            'bottom-left':  project(new THREE.Vector3(xLeft, -ph / 2, 0)),
            'bottom-right': project(new THREE.Vector3(xRight,-ph / 2, 0)),
        };
    }

    getBookScreenBounds() {
        if (!this.book) return null;

        // Get book world position corners
        const width = CONFIG.pageWidth * 2; // Full open book width
        const height = CONFIG.pageHeight;

        // Create corner points in world space
        const corners = [
            new THREE.Vector3(-width/2, height/2, 0),
            new THREE.Vector3(width/2, height/2, 0),
            new THREE.Vector3(-width/2, -height/2, 0),
            new THREE.Vector3(width/2, -height/2, 0)
        ];

        // Transform by book's world matrix
        corners.forEach(corner => {
            corner.applyMatrix4(this.book.matrixWorld);
        });

        // Project to screen
        const screenCorners = corners.map(corner => {
            const projected = corner.clone().project(this.camera);
            return {
                x: (projected.x + 1) / 2 * window.innerWidth,
                y: (-projected.y + 1) / 2 * window.innerHeight
            };
        });

        // Get bounding box
        const minX = Math.min(...screenCorners.map(c => c.x));
        const maxX = Math.max(...screenCorners.map(c => c.x));
        const minY = Math.min(...screenCorners.map(c => c.y));
        const maxY = Math.max(...screenCorners.map(c => c.y));

        return {
            width: maxX - minX,
            height: maxY - minY,
            centerX: (minX + maxX) / 2,
            centerY: (minY + maxY) / 2
        };
    }

    createBlankTexture() {
        // Create a blank white texture for empty pages
        const canvas = document.createElement('canvas');
        canvas.width = 512;
        canvas.height = 700;
        const ctx = canvas.getContext('2d', { alpha: false });
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        texture.needsUpdate = true;
        return texture;
    }

    createPage(frontPageNum, backPageNum) {
        // Get textures from pre-rendered PDF pages
        // Use blank texture if page not yet loaded (progressive loading) or beyond numPages
        const frontTexture = (frontPageNum <= this.numPages && this.pageTextures[frontPageNum - 1])
            ? this.pageTextures[frontPageNum - 1]
            : this.createBlankTexture();
        const backTexture = (backPageNum <= this.numPages && this.pageTextures[backPageNum - 1])
            ? this.pageTextures[backPageNum - 1]
            : this.createBlankTexture();

        // Use PlaneGeometry with segments for curl deformation
        const segments = 40; // More segments = smoother curl
        const geometry = new THREE.PlaneGeometry(
            CONFIG.pageWidth,
            CONFIG.pageHeight,
            segments,
            1
        );

        // Create materials based on paper type from CONFIG
        let materials;
        if (true) {
            // Matte (default) - flat, accurate colors, ignores lighting
            materials = [
                new THREE.MeshBasicMaterial({
                    map: frontTexture,
                    side: THREE.FrontSide
                }),
                new THREE.MeshBasicMaterial({
                    map: backTexture,
                    side: THREE.BackSide
                })
            ];
        }

        const frontMesh = new THREE.Mesh(geometry, materials[0]);

        // Clone geometry for back face and mirror its UVs horizontally.
        // The back mesh renders with THREE.BackSide; a plane viewed from behind
        // appears horizontally mirrored, so we flip the U coordinate (u -> 1-u)
        // in the GEOMETRY to compensate. Doing it here — instead of mutating the
        // texture's repeat.x — keeps page textures orientation-agnostic so they
        // can be safely shared, recreated by the GPU texture window, and reused
        // across page-mode rebuilds without ever leaking a mirror onto a front
        // face. (That shared-texture mutation was the cause of pages rendering
        // horizontally flipped on large books and after page-mode switches.)
        const backGeometry = geometry.clone();
        const backUV = backGeometry.attributes.uv;
        for (let i = 0; i < backUV.count; i++) {
            backUV.setX(i, 1 - backUV.getX(i));
        }
        backUV.needsUpdate = true;
        const backMesh = new THREE.Mesh(backGeometry, materials[1]);

        // Group both faces
        const page = new THREE.Group();
        page.add(frontMesh);
        page.add(backMesh);
        // Hardcover wrap is white; page-cast shadows on it look like dirt artifacts
        // (especially on weaker GPUs where PCF softening is coarse). Disable cast
        // shadow when hardcover so the cover stays clean. setFlipbookStyle() flips
        // this on existing pages when the style changes at runtime.
        page.castShadow = this.flipbookStyle !== 'hardcover';
        page.receiveShadow = true;

        // Store original positions for deformation
        const originalPositions = geometry.attributes.position.array.slice();

        // Create pivot for flipping (at left edge)
        const pivot = new THREE.Group();
        pivot.position.x = 0;
        page.position.x = CONFIG.pageWidth / 2;
        pivot.add(page);

        return {
            pivot,
            page,
            frontMesh,
            backMesh,
            geometry,
            backGeometry,
            originalPositions,
            flipped: false,
            frontPageNum,
            backPageNum,
            // Store material references for texture swapping during progressive loading
            frontMaterial: materials[0],
            backMaterial: materials[1]
        };
    }

    // ─────────────────────────────────────────────────────────────────
    // Ghost flippers for single-page mode (two-mesh "expose underneath")
    // ─────────────────────────────────────────────────────────────────
    //
    // Single-page mode keeps the visible page centered with the spine at
    // its left edge — exactly like a sheet in spread mode. Every turn
    // animates a 180° hinge rotation around that left spine. The page is
    // never shifted horizontally: book.x stays put.
    //
    // Two flipper meshes A and B alternate roles per turn:
    //   - Active   = currently visible at rest (the "current" page).
    //   - Inactive = hidden between turns.
    //
    // NEXT: active rotates 0 → -π (lifts and sweeps off to the left).
    //       Inactive sits at rotation 0 underneath it, showing the
    //       target page. As active rotates away, the target page is
    //       progressively exposed on screen.
    //
    // PREV: inactive starts at rotation -π (off-screen left) showing the
    //       previous page. It rotates -π → 0, sweeping in from the left
    //       and covering the still-visible current page underneath.
    //
    // After each flip we hide the mesh that "left view" and swap the
    // active flag so the mesh that took over is now the resting page.

    _ensureSinglePageFlippers() {
        if (this._spFlipperA && this._spFlipperB) {
            return;
        }
        this._spFlipperA = this.createPage(1, 2);
        this._spFlipperB = this.createPage(1, 2);
        this._spFlipperA.pivot.visible = false;
        this._spFlipperB.pivot.visible = false;
        this._spFlipperA.pivot.scale.set(0, 0, 0);
        this._spFlipperB.pivot.scale.set(0, 0, 0);
        this.book.add(this._spFlipperA.pivot);
        this.book.add(this._spFlipperB.pivot);
        this._spActiveId = 'A';
    }

    _spActive() { return this._spActiveId === 'A' ? this._spFlipperA : this._spFlipperB; }
    _spInactive() { return this._spActiveId === 'A' ? this._spFlipperB : this._spFlipperA; }
    _spSwapActive() { this._spActiveId = this._spActiveId === 'A' ? 'B' : 'A'; }

    _setFlipperTexture(material, pageNumber, isBack) {
        let src;
        if (pageNumber < 1 || pageNumber > this.numPages) {
            src = this.createBlankTexture();
        } else {
            src = this.pageTextures[pageNumber - 1] || this.createBlankTexture();
        }
        // Clone so the flipper owns its texture lifetime: the GPU texture window
        // can dispose the shared page texture (see _releasePageTexture) out from
        // under us mid-flip. Orientation is handled by the flipper's geometry
        // (its back mesh has mirrored UVs, built via createPage), so the texture
        // is used as-is — no repeat.x juggling. isBack is retained for call-site
        // readability and future use.
        const tex = src.clone();
        tex.needsUpdate = true;
        material.map = tex;
        material.needsUpdate = true;
    }

    _singlePageFlipperShowCurrent() {
        this._ensureSinglePageFlippers();
        const active = this._spActive();
        const inactive = this._spInactive();
        const currentPage = this.currentSinglePage || 1;

        // Hide all real sheets — the flippers are the visible pages now.
        this.pages.forEach((p) => {
            p.pivot.visible = false;
            p.pivot.scale.set(0, 0, 0);
        });

        // Active mesh = the visible centered current page.
        active.pivot.rotation.y = 0;
        active.pivot.position.y = 0;
        active.pivot.position.z = 0.06;
        active.pivot.scale.set(1, 1, 1);
        active.pivot.visible = true;
        this._setFlipperTexture(active.frontMaterial, currentPage, false);
        this._setFlipperTexture(active.backMaterial, currentPage + 1, true);

        // Inactive mesh = hidden, will be brought in as the "underneath"
        // mesh (next direction) or as the "incoming" mesh (prev) on click.
        inactive.pivot.visible = false;
        inactive.pivot.scale.set(0, 0, 0);
        inactive.pivot.rotation.y = 0;

        this.book.position.x = -CONFIG.pageWidth / 2;
        this.targetBookX = this.book.position.x;
    }

    _singlePageFlipperHide() {
        if (this._spFlipperA) { this._spFlipperA.pivot.visible = false; this._spFlipperA.pivot.scale.set(0, 0, 0); }
        if (this._spFlipperB) { this._spFlipperB.pivot.visible = false; this._spFlipperB.pivot.scale.set(0, 0, 0); }
    }

    // Instantly show `pageNum` as the centered single page using the ghost
    // flipper — the same system the toggle and prev/next arrows use. Used by
    // page-number / thumbnail / TOC jumps and first/last navigation in
    // single-page mode. The older real-sheet jump path left the flipper meshes
    // visible (two stacked pages) and used a parity-based book offset that
    // pushed the page off to one side; routing every jump through the flipper
    // keeps the page single and centered on every page.
    _singlePageJumpTo(pageNum, silent = false) {
        this.currentSinglePage = Math.max(1, Math.min(pageNum, this.numPages));
        if (!silent) this.playFlipSound();
        this._singlePageFlipperShowCurrent();
        this._singlePageFlipperReady = true;
        // Keep real sheets' flipped state in sync so exiting single-page mode
        // lands on the correct spread.
        const evenOffset = this.isEvenPageMode ? 1 : 0;
        const targetFlipped = Math.floor((this.currentSinglePage + evenOffset) / 2);
        this.pages.forEach((p, i) => { p.flipped = i < targetFlipped; });
        this.updateHardcoverVisibility();
        this.updateSpiralPosition();
        this.updateDropShadowLayout();
        if (this.dropShadow) this.dropShadow.visible = true;
        this.updatePageIndicator();
        this.updatePeelPositions();
    }

    _singlePageGhostFlip(targetPage, direction, silent = false) {
        if (this.isAnimating) return;
        this._ensureSinglePageFlippers();
        const currentPage = this.currentSinglePage;
        this.currentSinglePage = targetPage;

        this.isAnimating = true;
        if (this.dropShadow) this.dropShadow.visible = false;
        if (!silent) this.playFlipSound();

        // Hide all real sheets — flippers are the only visible pages.
        this.pages.forEach((p) => {
            p.pivot.visible = false;
            p.pivot.scale.set(0, 0, 0);
        });

        const active = this._spActive();
        const inactive = this._spInactive();

        // Pick which mesh rotates and which stays put based on direction.
        let rotatingMesh;
        let staticMesh;
        let startRotation;
        let targetRotation;
        if (direction === 'next') {
            // Current page rotates away to the left, target page sits
            // underneath at rotation 0 and is exposed as current rotates.
            rotatingMesh = active;
            staticMesh = inactive;
            startRotation = 0;
            targetRotation = -Math.PI;
            // Rotating mesh's front face is currentPage (already from the
            // last "showCurrent" or previous flip). Set back too so the
            // back face that becomes visible mid-rotation shows the target.
            this._setFlipperTexture(rotatingMesh.frontMaterial, currentPage, false);
            this._setFlipperTexture(rotatingMesh.backMaterial, targetPage, true);
            // Static (underneath) shows targetPage on its front face.
            this._setFlipperTexture(staticMesh.frontMaterial, targetPage, false);
            this._setFlipperTexture(staticMesh.backMaterial, targetPage + 1, true);
        } else {
            // Previous page rotates IN from off-screen left, covering the
            // current page that stays sitting at rotation 0 underneath.
            rotatingMesh = inactive;
            staticMesh = active;
            startRotation = -Math.PI;
            targetRotation = 0;
            // Rotating mesh shows targetPage on both faces (back visible
            // at start at rotation -π, front visible at end at rotation 0).
            this._setFlipperTexture(rotatingMesh.frontMaterial, targetPage, false);
            this._setFlipperTexture(rotatingMesh.backMaterial, targetPage, true);
            // Static (current, will be hidden after) — texture already set.
        }

        // Position both meshes at the centered book position. Z-order:
        // rotating mesh is slightly above static so it covers it before
        // the rotation has started (and remains in front for prev).
        rotatingMesh.pivot.rotation.y = startRotation;
        rotatingMesh.pivot.position.y = 0;
        rotatingMesh.pivot.position.z = 0.08;
        rotatingMesh.pivot.scale.set(1, 1, 1);
        rotatingMesh.pivot.visible = true;

        staticMesh.pivot.rotation.y = 0;
        staticMesh.pivot.position.y = 0;
        staticMesh.pivot.position.z = 0.05;
        staticMesh.pivot.scale.set(1, 1, 1);
        staticMesh.pivot.visible = true;

        // Book stays at the resting centered position — NO horizontal
        // shift during the flip. Page rotates in place around its own
        // left spine, just like a sheet does in spread mode.
        this.book.position.x = -CONFIG.pageWidth / 2;
        this.targetBookX = this.book.position.x;

        const startTime = Date.now();
        const duration = CONFIG.flipDuration;

        const animate = () => {
            const elapsed = Date.now() - startTime;
            const progress = Math.min(elapsed / duration, 1);
            const eased = 1 - Math.pow(1 - progress, 3);

            rotatingMesh.pivot.rotation.y = startRotation + (targetRotation - startRotation) * eased;

            const sineT = Math.sin(progress * Math.PI);
            rotatingMesh.pivot.position.y = sineT * 0.08;
            rotatingMesh.pivot.position.z = 0.08 + sineT * 0.05;
            this.applyCurl(rotatingMesh, sineT * 0.4, progress, direction);

            if (progress < 1) {
                requestAnimationFrame(animate);
                return;
            }

            // Animation complete. Hide whichever mesh is no longer the
            // visible page and promote the other one to active.
            this.resetPageCurl(rotatingMesh);
            rotatingMesh.pivot.position.y = 0;

            if (direction === 'next') {
                // The mesh that just rotated away (was active showing
                // currentPage) is now off to the left — hide it.
                rotatingMesh.pivot.visible = false;
                rotatingMesh.pivot.scale.set(0, 0, 0);
                rotatingMesh.pivot.rotation.y = 0;
                rotatingMesh.pivot.position.z = 0.06;
                // Static mesh is the new visible page (targetPage on front).
                staticMesh.pivot.position.z = 0.06;
                this._spSwapActive();
            } else {
                // Rotating mesh swept in from the left and is now the
                // visible centered page. Hide the static (was active,
                // showing currentPage that's now been replaced).
                staticMesh.pivot.visible = false;
                staticMesh.pivot.scale.set(0, 0, 0);
                rotatingMesh.pivot.position.z = 0.06;
                // Keep the rotating mesh's back face loaded with target+1
                // so a subsequent NEXT flip can render its back correctly.
                this._setFlipperTexture(rotatingMesh.backMaterial, targetPage + 1, true);
                this._spSwapActive();
            }

            // Sync real sheet .flipped state so exiting single-page mode
            // lands on the correct spread.
            const evenOffset = this.isEvenPageMode ? 1 : 0;
            const targetFlipped = Math.floor((targetPage + evenOffset) / 2);
            this.pages.forEach((p, i) => { p.flipped = i < targetFlipped; });

            this.isAnimating = false;
            this.updatePageIndicator();
            this.updatePeelPositions();
            this.updateDropShadowLayout();
            if (this.dropShadow) this.dropShadow.visible = true;
            if (this.spineShadow) this.spineShadow.visible = false;
            if (this.creaseLine) this.creaseLine.visible = false;
            this.updateHardcoverVisibility();
            this.updateSpiralPosition();
        };

        animate();
    }

    // Apply curl deformation to page vertices
    applyCurl(pageData, curlAmount, flipProgress, direction = 'prev') {
        if (this.is2D) return;
        const { geometry, backGeometry, originalPositions } = pageData;

        const posAttr = geometry.attributes.position;
        const backPosAttr = backGeometry.attributes.position;

        // Curl intensity multiplier from CONFIG (0.3 = subtle, 1.0 = normal, 1.5 = dramatic)
        const intensity = CONFIG.curlIntensity ?? 1.0;

        // Enhanced curl parameters
        const maxCurl = Math.PI * 0.7 * intensity;  // 126° at intensity 1.0 (was 90°)
        const curlRadius = 1.0 + (1 - curlAmount) * 0.8;

        // Curl direction: -1 for 'next' (curl away), 1 for 'prev' (curl toward)
        const curlDirection = direction === 'next' ? -1 : 1;

        for (let i = 0; i < posAttr.count; i++) {
            const origX = originalPositions[i * 3];
            const origY = originalPositions[i * 3 + 1];

            // Normalize x position (0 at spine, 1 at edge)
            const normalizedX = (origX + CONFIG.pageWidth / 2) / CONFIG.pageWidth;

            // Curl spreads more evenly across the page (1.6 power vs old 2.0)
            const curlFactor = Math.pow(normalizedX, 1.6) * curlAmount;
            const angle = curlFactor * maxCurl;

            // Enhanced cylindrical deformation — more dramatic Z displacement
            const newZ = Math.sin(angle) * curlRadius * normalizedX * 0.55 * intensity * curlDirection;

            // Visible wave ripple for paper realism
            const wave = Math.sin(normalizedX * Math.PI) * curlAmount * 0.1 * intensity * curlDirection;

            // Subtle Y-axis undulation — paper doesn't stay perfectly flat vertically
            const yWave = Math.sin(normalizedX * Math.PI * 2) * curlAmount * 0.02 * intensity;
            const newY = origY + yWave;

            posAttr.setXYZ(i, origX, newY, newZ + wave);
            backPosAttr.setXYZ(i, origX, newY, newZ + wave);
        }

        posAttr.needsUpdate = true;
        backPosAttr.needsUpdate = true;
        geometry.computeVertexNormals();
        backGeometry.computeVertexNormals();
    }

    // Reset page to flat state
    resetPageCurl(pageData) {
        if (!pageData) return;
        if (this.is2D) return; // No curl in 2D mode

        // Cancel any running lift animation
        if (pageData.liftAnimationId) {
            cancelAnimationFrame(pageData.liftAnimationId);
            pageData.liftAnimationId = null;
        }

        const { geometry, backGeometry, originalPositions } = pageData;

        const posAttr = geometry.attributes.position;
        const backPosAttr = backGeometry.attributes.position;

        for (let i = 0; i < posAttr.count; i++) {
            posAttr.setXYZ(
                i,
                originalPositions[i * 3],
                originalPositions[i * 3 + 1],
                originalPositions[i * 3 + 2]
            );
            backPosAttr.setXYZ(
                i,
                originalPositions[i * 3],
                originalPositions[i * 3 + 1],
                originalPositions[i * 3 + 2]
            );
        }

        // Reset lift amount state
        pageData.currentLiftAmount = 0;

        posAttr.needsUpdate = true;
        backPosAttr.needsUpdate = true;
        geometry.computeVertexNormals();
        backGeometry.computeVertexNormals();
    }

    // ==========================================
    // FLIP SHADOW — dynamic shadow under turning page
    // ==========================================

    _createFlipShadow(pageData, direction) {
        // Shadow sits on the page BELOW the flipping page (stationary, not rotating)
        if (pageData._flipShadow) return; // already exists

        // Convert string direction to numeric: 'next' = 1, 'prev' = -1
        const dir = (direction === 'next' || direction === 1) ? 1 : -1;

        const shadowW = CONFIG.pageWidth;
        const shadowH = CONFIG.pageHeight;

        // Use a canvas texture for a soft gradient shadow
        const canvas = document.createElement('canvas');
        canvas.width = 256;
        canvas.height = 256;
        const ctx = canvas.getContext('2d');

        // Linear gradient from spine edge (dark) to outer edge (transparent)
        // This simulates shadow cast by the lifted page
        const grad = ctx.createLinearGradient(0, 0, canvas.width, 0);
        grad.addColorStop(0, 'rgba(0, 0, 0, 0.5)');
        grad.addColorStop(0.3, 'rgba(0, 0, 0, 0.25)');
        grad.addColorStop(0.6, 'rgba(0, 0, 0, 0.1)');
        grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
        ctx.fillStyle = grad;
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        const texture = new THREE.CanvasTexture(canvas);

        const geo = new THREE.PlaneGeometry(shadowW, shadowH);
        const mat = new THREE.MeshBasicMaterial({
            map: texture,
            transparent: true,
            opacity: 0,
            depthWrite: false,
            side: THREE.DoubleSide
        });

        const shadowMesh = new THREE.Mesh(geo, mat);
        shadowMesh.renderOrder = 5;

        // Add to scene (not to pivot) so it stays stationary
        this.scene.add(shadowMesh);

        // Position on the page being revealed (below the flipping page)
        // Forward flip ('next'/1): shadow goes on the RIGHT page (positive X)
        // Backward flip ('prev'/-1): shadow goes on the LEFT page (negative X)
        if (dir === 1) {
            // Flipping forward: shadow on right page, gradient from spine outward
            shadowMesh.position.set(CONFIG.pageWidth / 2, 0, CONFIG.pageThickness + 0.01);
            shadowMesh.rotation.z = 0; // gradient goes left-to-right (spine to edge)
        } else {
            // Flipping backward: shadow on left page, gradient from spine outward
            shadowMesh.position.set(-CONFIG.pageWidth / 2, 0, CONFIG.pageThickness + 0.01);
            shadowMesh.rotation.z = Math.PI; // flip gradient direction
        }

        pageData._flipShadow = shadowMesh;
        pageData._flipShadowDirection = dir;
    }

    _updateFlipShadow(pageData, progress, direction) {
        if (!pageData._flipShadow) return;

        // Convert string direction to numeric if needed
        const dir = (direction === 'next' || direction === 1) ? 1 : -1;

        const shadow = pageData._flipShadow;
        const mat = shadow.material;

        // Shadow intensity peaks at mid-flip and fades at start/end
        const intensity = Math.sin(progress * Math.PI);

        // Opacity: strongest in the middle of the flip
        mat.opacity = intensity * 0.6;

        // Scale shadow width based on how high the page is lifted
        // At mid-flip, page is highest = widest shadow
        const scaleX = 0.4 + intensity * 0.6;
        shadow.scale.set(scaleX, 1, 1);

        // Shift shadow position based on flip progress
        // As page rotates, shadow edge moves across the stationary page
        const shiftAmount = (1 - intensity) * CONFIG.pageWidth * 0.3;
        if (dir === 1) {
            // Forward: shadow starts at spine, shifts right as flip completes
            shadow.position.x = CONFIG.pageWidth / 2 + shiftAmount * progress;
        } else {
            // Backward: shadow starts at spine, shifts left as flip completes
            shadow.position.x = -CONFIG.pageWidth / 2 - shiftAmount * progress;
        }
    }

    _removeFlipShadow(pageData) {
        if (!pageData._flipShadow) return;
        this.scene.remove(pageData._flipShadow);
        pageData._flipShadow.material.map.dispose();
        pageData._flipShadow.material.dispose();
        pageData._flipShadow.geometry.dispose();
        pageData._flipShadow = null;
        pageData._flipShadowDirection = null;
    }

    // Apply corner lift effect for hover
    applyCornerLift(pageData, corner, amount, isFlipped = false) {
        // Strict guards - don't apply if animating, dragging, or pending drag
        if (!pageData || this.isAnimating || this.isDragging || this.dragPending) return;

        const { geometry, backGeometry, originalPositions } = pageData;
        const posAttr = geometry.attributes.position;
        const backPosAttr = backGeometry.attributes.position;

        const pageWidth = CONFIG.pageWidth;
        const pageHeight = CONFIG.pageHeight;

        // Corner lift radius
        const cornerRadius = 0.22;

        // Determine visual corner position
        let isLeftCorner = corner === 'top-left' || corner === 'bottom-left';
        const isTopCorner = corner === 'top-right' || corner === 'top-left';

        // For flipped sheets, the geometry is rotated 180°, so left/right are swapped visually
        // When user hovers visual "left" on a flipped sheet, that's actually local "right" (x=1)
        // When user hovers visual "right" on a flipped sheet, that's actually local "left" (x=0)
        if (isFlipped) {
            isLeftCorner = !isLeftCorner;
        }

        for (let i = 0; i < posAttr.count; i++) {
            const origX = originalPositions[i * 3];
            const origY = originalPositions[i * 3 + 1];
            const origZ = originalPositions[i * 3 + 2];

            // Normalize coordinates (0 to 1)
            const normX = (origX + pageWidth / 2) / pageWidth;
            const normY = (origY + pageHeight / 2) / pageHeight;

            // Calculate distance from the target corner in LOCAL coordinates
            const cornerX = isLeftCorner ? 0 : 1;
            const cornerY = isTopCorner ? 1 : 0;

            const distFromCorner = Math.sqrt(
                Math.pow(cornerX - normX, 2) +
                Math.pow(cornerY - normY, 2)
            );

            // Sharp falloff - only lift vertices close to corner
            let liftFactor = Math.max(0, 1 - distFromCorner / cornerRadius);
            liftFactor = Math.pow(liftFactor, 3);

            // Lift direction - always lift toward viewer
            // For unflipped: +Z is toward viewer
            // For flipped: -Z is toward viewer (due to 180° rotation)
            const liftDirection = isFlipped ? -1 : 1;

            const lift = liftFactor * amount * 0.12 * liftDirection;

            posAttr.setXYZ(i, origX, origY, origZ + lift);
            backPosAttr.setXYZ(i, origX, origY, origZ + lift);
        }

        posAttr.needsUpdate = true;
        backPosAttr.needsUpdate = true;
        geometry.computeVertexNormals();
        backGeometry.computeVertexNormals();
    }

    // Animate corner lift smoothly
    animateCornerLift(pageData, corner, targetAmount) {
        if (!pageData || this.is2D) return;

        // Don't start lift animation if we're already in a state that prevents it
        if (this.isAnimating || this.isDragging || this.dragPending) {
            return;
        }

        // Cancel any existing lift animation on this page
        if (pageData.liftAnimationId) {
            cancelAnimationFrame(pageData.liftAnimationId);
            pageData.liftAnimationId = null;
        }

        const startAmount = pageData.currentLiftAmount || 0;
        const startTime = Date.now();
        const duration = 200;
        const isFlipped = pageData.flipped;

        const animate = () => {
            // Stop immediately if any state changed that prevents lifting
            if (this.isAnimating || this.isDragging || this.dragPending) {
                this.resetPageCurl(pageData);
                pageData.liftAnimationId = null;
                pageData.currentLiftAmount = 0;
                return;
            }

            // Also stop if this page is no longer the hovered page
            if (this.hoveredPage !== pageData && targetAmount > 0) {
                this.resetPageCurl(pageData);
                pageData.liftAnimationId = null;
                pageData.currentLiftAmount = 0;
                return;
            }

            const elapsed = Date.now() - startTime;
            const progress = Math.min(elapsed / duration, 1);
            const eased = 1 - Math.pow(1 - progress, 3);

            const currentAmount = startAmount + (targetAmount - startAmount) * eased;
            pageData.currentLiftAmount = currentAmount;

            if (currentAmount > 0.01 || targetAmount > 0) {
                this.applyCornerLift(pageData, corner, currentAmount, isFlipped);
            } else {
                this.resetPageCurl(pageData);
                pageData.currentLiftAmount = 0;
            }

            if (progress < 1) {
                pageData.liftAnimationId = requestAnimationFrame(animate);
            } else {
                pageData.liftAnimationId = null;
            }
        };

        pageData.liftAnimationId = requestAnimationFrame(animate);
    }

    createBook() {
        if (this.is2D) { this._2d_createBook(); return; }
        this.book = new THREE.Group();

        // Calculate number of physical sheets needed
        // Each sheet has 2 sides, so we need ceil(numPages/2) sheets
        // In even mode, prepend a virtual blank page (page 0) to shift pairing
        const evenOffset = this.isEvenPageMode ? 1 : 0;
        const numSheets = Math.ceil((this.numPages + evenOffset) / 2);

        // Create pages (sheets)
        for (let i = 0; i < numSheets; i++) {
            // Front side shows odd page, back shows even page
            // In even mode, offset shifts so sheet 0 = front:page0(blank), back:page1
            const frontPageNum = i * 2 + 1 - evenOffset;
            const backPageNum = i * 2 + 2 - evenOffset;

            const pageData = this.createPage(frontPageNum, backPageNum);
            pageData.pivot.position.z = (numSheets / 2 - i) * CONFIG.pageDepth;

            // All pages start unflipped (book opens at page 1 / cover)

            this.pages.push(pageData);
            this.book.add(pageData.pivot);
        }

        // Pre-flip first sheet in even page mode
        // Sheet 0 has front=page0(blank), back=page1; flipping it shows page 1 on left
        if (this.isEvenPageMode && this.pages.length > 0) {
            this.pages[0].flipped = true;
            this.pages[0].pivot.rotation.y = -Math.PI;
        }

        // Add center spine shadow
        this.createSpineShadow();

        // Add premium drop shadow around book edges (if enabled)
        if (CONFIG.dropShadowEnabled !== false) {
            this.createBookDropShadow();
        }

        // Position and rotate book (tilt angle from CONFIG)
        const bookTiltDeg = CONFIG.bookTilt ?? 8;
        this.book.rotation.x = -(bookTiltDeg * Math.PI) / 180;

        this.scene.add(this.book);

        // Update z-ordering for initial state
        this.updatePageZOrder();

        // Set initial spread (start at cover / page 1)
        this.currentSpread = 0;
        this.updatePageIndicator();

        // Set initial view mode position
        this.book.position.x = this.getTargetBookPosition();
        this.targetBookX = this.book.position.x;
        this.updateViewMode();
    }

    createSpineShadow() {
        // Just the center crease line - clean and minimal
        this.spineShadow = null; // No spread shadow

        // Center crease line - very subtle thin line
        const creaseCanvas = document.createElement('canvas');
        creaseCanvas.width = 16;
        creaseCanvas.height = 1;
        const creaseCtx = creaseCanvas.getContext('2d');

        const creaseGradient = creaseCtx.createLinearGradient(0, 0, creaseCanvas.width, 0);
        creaseGradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
        creaseGradient.addColorStop(0.4, 'rgba(0, 0, 0, 0.06)');
        creaseGradient.addColorStop(0.5, 'rgba(0, 0, 0, 0.12)');
        creaseGradient.addColorStop(0.6, 'rgba(0, 0, 0, 0.06)');
        creaseGradient.addColorStop(1, 'rgba(0, 0, 0, 0)');

        creaseCtx.fillStyle = creaseGradient;
        creaseCtx.fillRect(0, 0, creaseCanvas.width, creaseCanvas.height);

        const creaseTexture = new THREE.CanvasTexture(creaseCanvas);
        creaseTexture.needsUpdate = true;

        const creaseGeometry = new THREE.PlaneGeometry(0.03, CONFIG.pageHeight);
        const creaseMaterial = new THREE.MeshBasicMaterial({
            map: creaseTexture,
            transparent: true,
            opacity: 1,
            depthWrite: false
        });

        const creaseLine = new THREE.Mesh(creaseGeometry, creaseMaterial);
        creaseLine.position.set(0, 0, 0.026);
        creaseLine.renderOrder = 11;

        this.book.add(creaseLine);
        this.creaseLine = creaseLine;
    }

    createBookDropShadow() {
        const shadowPad = 0.35;

        // Store base dimensions
        this._shadowPad = shadowPad;
        this._shadowSingleView = false; // track current state

        const bookW = CONFIG.pageWidth * 2;
        const bookH = CONFIG.pageHeight;
        const planeW = bookW + shadowPad * 2;
        const planeH = bookH + shadowPad * 2;

        const geo = new THREE.PlaneGeometry(planeW, planeH);
        const shadowOpacity = CONFIG.shadowOpacity ?? 0.5;
        const material = new THREE.MeshBasicMaterial({
            transparent: true,
            opacity: shadowOpacity,
            depthWrite: false,
            side: THREE.DoubleSide
        });

        this.dropShadow = new THREE.Mesh(geo, material);
        this.dropShadow.position.set(0, 0, -0.06);
        this.dropShadow.renderOrder = -2;
        this.book.add(this.dropShadow);
        this._shadowOpacity = shadowOpacity;

        // Paint initial shadow
        this._rebuildDropShadow(false);
    }

    // Called by theme toggle and layout changes
    updateDropShadow() {
        if (!this.dropShadow) return;
        this._rebuildDropShadow(this._shadowSingleView || false);
    }

    _rebuildDropShadow(isSingleView) {
        if (!this.dropShadow) return;

        const shadowPad = this._shadowPad || 0.35;
        const bookW = isSingleView ? CONFIG.pageWidth : CONFIG.pageWidth * 2;
        const bookH = CONFIG.pageHeight;
        const planeW = bookW + shadowPad * 2;
        const planeH = bookH + shadowPad * 2;

        // Rebuild geometry to correct size
        if (this.dropShadow.geometry) {
            this.dropShadow.geometry.dispose();
        }
        this.dropShadow.geometry = new THREE.PlaneGeometry(planeW, planeH);

        const isDark = this.isDarkTheme !== false;

        const res = 512;
        const canvas = document.createElement('canvas');
        const aspect = planeW / planeH;
        canvas.width = Math.round(res * aspect);
        canvas.height = res;
        const ctx = canvas.getContext('2d');

        const padPxX = (shadowPad / planeW) * canvas.width;
        const padPxY = (shadowPad / planeH) * canvas.height;
        const innerX = padPxX;
        const innerY = padPxY;
        const innerW = canvas.width - padPxX * 2;
        const innerH = canvas.height - padPxY * 2;

        if (isDark) {
            // Hard 3D edge shadow for dark mode — tight, no spread
            // Mimics a physical book sitting on a dark surface with edge lighting

            // 1) Very tight dark drop underneath (offset down-right, barely spreads)
            const shadowLayers = 5;
            for (let i = shadowLayers; i >= 0; i--) {
                const t = i / shadowLayers;
                const offsetX = (1 - t) * 3;
                const offsetY = (1 - t) * 4;
                const expand = t * 2;
                const alpha = (1 - t) * 0.25;

                ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`;
                ctx.beginPath();
                ctx.roundRect(
                    innerX + offsetX - expand,
                    innerY + offsetY - expand,
                    innerW + expand * 2,
                    innerH + expand * 2,
                    3
                );
                ctx.fill();
            }

            // 2) Hard edge — crisp 1px border right at the book edge
            ctx.strokeStyle = 'rgba(0, 0, 0, 0.6)';
            ctx.lineWidth = 1.5;
            ctx.beginPath();
            ctx.roundRect(innerX - 0.5, innerY - 0.5, innerW + 1, innerH + 1, 2);
            ctx.stroke();

            // 3) Thin highlight on top-left edges (light source from top-left)
            // Top edge highlight
            const topHL = ctx.createLinearGradient(innerX, innerY - 1, innerX, innerY + 3);
            topHL.addColorStop(0, 'rgba(255, 255, 255, 0.10)');
            topHL.addColorStop(1, 'rgba(255, 255, 255, 0)');
            ctx.fillStyle = topHL;
            ctx.fillRect(innerX + 2, innerY - 1, innerW - 4, 4);

            // Left edge highlight
            const leftHL = ctx.createLinearGradient(innerX - 1, innerY, innerX + 3, innerY);
            leftHL.addColorStop(0, 'rgba(255, 255, 255, 0.07)');
            leftHL.addColorStop(1, 'rgba(255, 255, 255, 0)');
            ctx.fillStyle = leftHL;
            ctx.fillRect(innerX - 1, innerY + 2, 4, innerH - 4);

            // 4) Darker bottom-right edges (away from light)
            const bottomDK = ctx.createLinearGradient(innerX, innerY + innerH - 3, innerX, innerY + innerH + 1);
            bottomDK.addColorStop(0, 'rgba(0, 0, 0, 0)');
            bottomDK.addColorStop(1, 'rgba(0, 0, 0, 0.15)');
            ctx.fillStyle = bottomDK;
            ctx.fillRect(innerX + 2, innerY + innerH - 3, innerW - 4, 4);

            const rightDK = ctx.createLinearGradient(innerX + innerW - 3, innerY, innerX + innerW + 1, innerY);
            rightDK.addColorStop(0, 'rgba(0, 0, 0, 0)');
            rightDK.addColorStop(1, 'rgba(0, 0, 0, 0.12)');
            ctx.fillStyle = rightDK;
            ctx.fillRect(innerX + innerW - 3, innerY + 2, 4, innerH - 4);
        } else {
            // Classic dark shadow for light backgrounds
            const layers = 20;
            for (let i = layers; i >= 0; i--) {
                const t = i / layers;
                const expand = t * Math.min(padPxX, padPxY);
                const radius = 6 + t * 14;
                const alpha = (1 - t) * 0.022;

                ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`;
                ctx.beginPath();
                ctx.roundRect(
                    innerX - expand,
                    innerY - expand,
                    innerW + expand * 2,
                    innerH + expand * 2,
                    radius
                );
                ctx.fill();
            }
        }

        const texture = new THREE.CanvasTexture(canvas);
        texture.needsUpdate = true;

        // Dispose old texture
        if (this.dropShadow.material.map) {
            this.dropShadow.material.map.dispose();
        }
        this.dropShadow.material.map = texture;
        this.dropShadow.material.needsUpdate = true;
    }

    updateDropShadowLayout() {
        if (!this.dropShadow) return;

        const flippedCount = this.pages ? this.pages.filter(p => p.flipped).length : 0;
        const isSingleView = this.forceSinglePage || flippedCount === this.minFlipped || flippedCount === this.pages.length;

        if (isSingleView) {
            // Determine which side the page is on
            if (this.forceSinglePage) {
                // In forced single-page mode the ghost flipper always keeps the
                // page centered: the book is shifted by -pageWidth/2 and the page
                // sits at +pageWidth/2 within it, so the page lands at world x=0
                // on every page. The shadow must therefore sit at +pageWidth/2 in
                // book space regardless of page parity. The old parity-based
                // offset placed the shadow a full page-width to the left on even
                // pages — the stray dark panel.
                this.dropShadow.position.x = CONFIG.pageWidth / 2;
            } else if (flippedCount === this.minFlipped) {
                this.dropShadow.position.x = CONFIG.pageWidth / 2;
            } else {
                this.dropShadow.position.x = -CONFIG.pageWidth / 2;
            }
        } else {
            this.dropShadow.position.x = 0;
        }

        // Rebuild shadow geometry + texture if view changed
        if (isSingleView !== this._shadowSingleView) {
            this._shadowSingleView = isSingleView;
            this._rebuildDropShadow(isSingleView);
        }

        // Hide crease line on single page view
        if (this.creaseLine) {
            this.creaseLine.visible = !isSingleView;
        }
    }

    // ==========================================
    // PDF Link Overlay System
    // ==========================================

    buildLinkOverlay() {
        // Rebuild link DOM elements when visible pages change
        const overlay = document.getElementById('pdfLinkOverlay');
        if (!overlay) return;

        overlay.innerHTML = '';
        this._linkElements = [];

        // Skip if PDF link overlay is disabled
        if (CONFIG.pdfLinkOverlay === false) return;

        // Hide in vertical/horizontal viewer (they have their own links)
        if (this.isVerticalViewer || this.isHorizontalViewer) return;
        if ((!this.book && !this._2d_bookEl) || !this.pageAnnotations.length) return;

        const visiblePages = this.getVisiblePageNumbers();
        if (!visiblePages.length) return;

        for (const pageNum of visiblePages) {
            const links = this.pageAnnotations[pageNum - 1];
            if (!links || !links.length) continue;

            for (const link of links) {
                const el = document.createElement('div');
                el.className = 'pdf-link';
                el.title = link.type === 'external' ? link.url : 'Go to page';

                el.addEventListener('click', (e) => {
                    e.stopPropagation();
                    e.preventDefault();
                    this.handleLinkClick(link);
                });

                overlay.appendChild(el);
                this._linkElements.push({ el, link, pageNum });
            }
        }

        // Position immediately
        this.repositionLinkOverlay();
    }

    repositionLinkOverlay() {
        // Reposition existing link elements to match current screen projection
        if (!this._linkElements || !this._linkElements.length) return;
        if (this.isVerticalViewer || this.isHorizontalViewer) return;

        // Hide links during flip animations to avoid visual artifacts
        const overlay = document.getElementById('pdfLinkOverlay');
        if (overlay) overlay.style.opacity = this.isAnimating ? '0' : '1';

        // Cache bounds per page to avoid recalculating for each link
        const boundsCache = {};
        for (const { el, link, pageNum } of this._linkElements) {
            if (!boundsCache[pageNum]) {
                boundsCache[pageNum] = this.getPageScreenBounds(pageNum);
            }
            const bounds = boundsCache[pageNum];
            if (!bounds) {
                el.style.display = 'none';
                continue;
            }

            el.style.display = '';
            el.style.left = (bounds.left + link.x * bounds.width) + 'px';
            el.style.top = (bounds.top + link.y * bounds.height) + 'px';
            el.style.width = (link.w * bounds.width) + 'px';
            el.style.height = (link.h * bounds.height) + 'px';
        }
    }

    getVisiblePageNumbers() {
        if (this.forceSinglePage) {
            return [this.currentSinglePage];
        }

        const flippedCount = this.pages.filter(p => p.flipped).length;
        const isAtStart = flippedCount === this.minFlipped;
        const isAtEnd = flippedCount === this.pages.length;
        const pages = [];

        if (isAtStart) {
            pages.push(1);
            if (this.isEvenPageMode && this.numPages > 1) pages.push(2);
        } else if (isAtEnd) {
            pages.push(this.numPages);
        } else {
            const leftPage = this.isEvenPageMode ? flippedCount * 2 - 1 : flippedCount * 2;
            const rightPage = leftPage + 1;
            if (leftPage >= 1 && leftPage <= this.numPages) pages.push(leftPage);
            if (rightPage >= 1 && rightPage <= this.numPages) pages.push(rightPage);
        }

        return pages;
    }

    // Return the 4 projected screen-space corners of a page in polygon order
    // [TL, TR, BR, BL] (viewport-pixel coords). Returns null if unavailable.
    _getPageScreenCorners(pageNum) {
        if (this.is2D) {
            const b = this._2d_getPageScreenBounds(pageNum);
            if (!b) return null;
            return [
                { x: b.left,            y: b.top },
                { x: b.left + b.width,  y: b.top },
                { x: b.left + b.width,  y: b.top + b.height },
                { x: b.left,            y: b.top + b.height },
            ];
        }
        const bounds = this.getPageScreenBounds(pageNum);
        if (!bounds) return null;
        const c = this._lastProjectedCorners;
        if (!c) return null;
        // Reorder [TL, TR, BL, BR] (computation order) → [TL, TR, BR, BL] (CW polygon).
        return [c[0], c[1], c[3], c[2]];
    }

    getPageScreenBounds(pageNum) {
        if (this.is2D) return this._2d_getPageScreenBounds(pageNum);
        if (!this.book) return null;

        const pageWidth = CONFIG.pageWidth;
        const pageHeight = CONFIG.pageHeight;

        // Determine if this page is on left or right side
        const pw = pageWidth;
        let corners;
        if (this.forceSinglePage) {
            const isOddPage = pageNum % 2 === 1;
            if (isOddPage) {
                corners = [
                    new THREE.Vector3(0, pageHeight / 2, 0),
                    new THREE.Vector3(pw, pageHeight / 2, 0),
                    new THREE.Vector3(0, -pageHeight / 2, 0),
                    new THREE.Vector3(pw, -pageHeight / 2, 0)
                ];
            } else {
                corners = [
                    new THREE.Vector3(-pw, pageHeight / 2, 0),
                    new THREE.Vector3(0, pageHeight / 2, 0),
                    new THREE.Vector3(-pw, -pageHeight / 2, 0),
                    new THREE.Vector3(0, -pageHeight / 2, 0)
                ];
            }
        } else {
            // Double page mode
            const flippedCount = this.pages.filter(p => p.flipped).length;
            const isAtStart = flippedCount === this.minFlipped;
            const isAtEnd = flippedCount === this.pages.length;
            let isLeftPage, isRightPage;
            if (this.isEvenPageMode) {
                isLeftPage = (pageNum % 2 === 1) && !isAtStart;
                isRightPage = (pageNum % 2 === 0) || isAtStart;
            } else {
                isLeftPage = (pageNum % 2 === 0) && !isAtStart;
                isRightPage = (pageNum % 2 === 1) || isAtStart;
            }

            if (isAtEnd) {
                // Last page shown alone - always on the left side
                corners = [
                    new THREE.Vector3(-pw, pageHeight / 2, 0),
                    new THREE.Vector3(0, pageHeight / 2, 0),
                    new THREE.Vector3(-pw, -pageHeight / 2, 0),
                    new THREE.Vector3(0, -pageHeight / 2, 0)
                ];
            } else if (isAtStart || isRightPage) {
                // Right page (unflipped side)
                corners = [
                    new THREE.Vector3(0, pageHeight / 2, 0),
                    new THREE.Vector3(pw, pageHeight / 2, 0),
                    new THREE.Vector3(0, -pageHeight / 2, 0),
                    new THREE.Vector3(pw, -pageHeight / 2, 0)
                ];
            } else {
                // Left page (flipped side)
                corners = [
                    new THREE.Vector3(-pw, pageHeight / 2, 0),
                    new THREE.Vector3(0, pageHeight / 2, 0),
                    new THREE.Vector3(-pw, -pageHeight / 2, 0),
                    new THREE.Vector3(0, -pageHeight / 2, 0)
                ];
            }
        }

        // Transform corners through the actual page mesh's world matrix when
        // available — picks up the sheet's pivot z-offset (sheets stack along
        // z by pageDepth) and any pivot rotation. book.matrixWorld alone
        // ignores those and produces a small but visible vertical drift under
        // perspective/tilt. Mesh-local corners are simply (±pw/2, ±ph/2, 0)
        // since the mesh group sits centered in its own local space.
        let mesh = null;
        // Single-page mode draws the visible page on the active ghost flipper —
        // the real sheets are hidden and scaled to (0,0,0). Using the hidden
        // sheet's matrix here collapses the projected bounds to a point, which
        // breaks link/media/hotspot overlays (they end up zero-sized/mispositioned
        // and unclickable). Use the flipper's front mesh, which is what's actually
        // on screen for the current page.
        if (this.forceSinglePage && this._singlePageFlipperReady &&
            pageNum === this.currentSinglePage && typeof this._spActive === 'function') {
            const active = this._spActive();
            if (active && active.frontMesh) {
                mesh = active.frontMesh;
            }
        }
        if (!mesh) {
            for (const p of this.pages) {
                if (p && (p.frontPageNum === pageNum || p.backPageNum === pageNum)) {
                    mesh = p.frontMesh || null;
                    break;
                }
            }
        }
        if (mesh) {
            const hw = pageWidth / 2, hh = pageHeight / 2;
            corners = [
                new THREE.Vector3(-hw,  hh, 0), // TL
                new THREE.Vector3( hw,  hh, 0), // TR
                new THREE.Vector3(-hw, -hh, 0), // BL
                new THREE.Vector3( hw, -hh, 0), // BR
            ];
            mesh.updateMatrixWorld(true);
            corners.forEach(c => c.applyMatrix4(mesh.matrixWorld));
        } else {
            this.book.updateMatrixWorld(true);
            corners.forEach(c => c.applyMatrix4(this.book.matrixWorld));
        }

        // Project to screen. Use the renderer canvas's getBoundingClientRect
        // so any parent transform (e.g. #canvas-container's translateY(-30px))
        // is reflected in the returned viewport coords — overlays positioned
        // with these values then line up with what's actually drawn.
        const canvasRect = this.renderer && this.renderer.domElement
            ? this.renderer.domElement.getBoundingClientRect()
            : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
        const screenCorners = corners.map(c => {
            const projected = c.clone().project(this.camera);
            return {
                x: canvasRect.left + (projected.x + 1) / 2 * canvasRect.width,
                y: canvasRect.top  + (-projected.y + 1) / 2 * canvasRect.height
            };
        });
        // Cache for _getPageScreenCorners(); order is [TL, TR, BL, BR].
        this._lastProjectedCorners = screenCorners;

        const minX = Math.min(...screenCorners.map(c => c.x));
        const maxX = Math.max(...screenCorners.map(c => c.x));
        const minY = Math.min(...screenCorners.map(c => c.y));
        const maxY = Math.max(...screenCorners.map(c => c.y));

        return {
            left: minX,
            top: minY,
            width: maxX - minX,
            height: maxY - minY
        };
    }

    async handleLinkClick(link) {
        // Track link click
        const currentPage = this.forceSinglePage ? this.currentSinglePage :
            (this.pages.filter(p => p.flipped).length * 2 || 1);
        analytics.trackLinkClick(link.url || 'internal', currentPage);

        if (link.type === 'external') {
            if (CONFIG.linkTarget === 'same') {
                window.location.href = link.url;
            } else {
                window.open(link.url, '_blank', 'noopener,noreferrer');
            }
        } else if (link.type === 'internal') {
            // Pass sourcePage for multi-PDF link resolution
            const pageNum = await this.resolvePageDest(link.dest, link.sourcePage || 1);
            if (pageNum) {
                this.goToPage(pageNum);
            }
        }
    }

    createViewerPageLinks(pageNum, wrapper) {
        const links = this.pageAnnotations[pageNum - 1];
        if (!links || !links.length) return;

        const linksContainer = document.createElement('div');
        linksContainer.className = 'viewer-page-links';

        for (const link of links) {
            const el = document.createElement('div');
            el.className = 'pdf-link';
            el.title = link.type === 'external' ? link.url : 'Go to page';

            el.style.left = (link.x * 100) + '%';
            el.style.top = (link.y * 100) + '%';
            el.style.width = (link.w * 100) + '%';
            el.style.height = (link.h * 100) + '%';

            el.addEventListener('click', (e) => {
                e.stopPropagation();
                e.preventDefault();
                this.handleLinkClick(link);
            });

            linksContainer.appendChild(el);
        }

        wrapper.appendChild(linksContainer);
    }

    // ==========================================
    // PDF MEDIA OVERLAY (video, audio, 3D, rich media)
    // ==========================================

    buildMediaOverlay() {
        this._destroyActiveMedia();
        const overlay = document.getElementById('pdfMediaOverlay');
        if (!overlay) return;
        overlay.innerHTML = '';
        this._mediaElements = [];

        // Skip if PDF link overlay is disabled (covers all PDF annotations)
        if (CONFIG.pdfLinkOverlay === false) return;

        if (this.isVerticalViewer || this.isHorizontalViewer) return;
        if ((!this.book && !this._2d_bookEl) || !this.pageMediaAnnotations.length) return;

        const visiblePages = this.getVisiblePageNumbers();
        if (!visiblePages.length) return;

        for (const pageNum of visiblePages) {
            const annotations = this.pageMediaAnnotations[pageNum - 1];
            if (!annotations || !annotations.length) continue;

            for (const annot of annotations) {
                const wrapper = document.createElement('div');
                wrapper.className = 'media-element';
                wrapper.dataset.annotId = annot.id;
                wrapper.dataset.pageNum = pageNum;

                const content = this._createMediaElement(annot);
                if (content) {
                    wrapper.appendChild(content);
                    overlay.appendChild(wrapper);
                    this._mediaElements.push({ el: wrapper, annot, pageNum });
                }
            }
        }

        this.repositionMediaOverlay();
    }

    _createMediaElement(annot) {
        if (annot.subtype === 'Movie' || annot.subtype === 'Screen') {
            if (annot.mediaType === 'audio') return this._createAudioPlayer(annot);
            return this._createVideoPlayer(annot);
        } else if (annot.subtype === '3D') {
            return this._create3DViewer(annot);
        } else if (annot.subtype === 'RichMedia') {
            return this._createRichMediaWidget(annot);
        }
        return this._createMediaPlaceholder(annot, 'Media');
    }

    _createVideoPlayer(annot) {
        const src = this._resolveMediaSource(annot);
        if (!src) return this._createMediaPlaceholder(annot, 'Video');

        const container = document.createElement('div');
        container.className = 'media-player-container';

        const video = document.createElement('video');
        video.src = src;
        video.preload = 'metadata';
        video.playsInline = true;
        if (annot.mimeType) video.type = annot.mimeType;
        container.appendChild(video);

        // Custom controls
        const controls = document.createElement('div');
        controls.className = 'media-player-controls';

        const playBtn = document.createElement('button');
        playBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
        const pauseIcon = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>';
        const playIcon = playBtn.innerHTML;

        const progress = document.createElement('div');
        progress.className = 'media-progress';
        const progressFill = document.createElement('div');
        progressFill.className = 'media-progress-fill';
        progress.appendChild(progressFill);

        const timeDisplay = document.createElement('span');
        timeDisplay.className = 'media-time';
        timeDisplay.textContent = '0:00';

        const fsBtn = document.createElement('button');
        fsBtn.title = 'Fullscreen';
        fsBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>';

        controls.appendChild(playBtn);
        controls.appendChild(progress);
        controls.appendChild(timeDisplay);
        controls.appendChild(fsBtn);
        container.appendChild(controls);

        // Event handlers
        playBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            if (video.paused) { video.play(); playBtn.innerHTML = pauseIcon; }
            else { video.pause(); playBtn.innerHTML = playIcon; }
        });
        video.addEventListener('click', () => {
            if (video.paused) { video.play(); playBtn.innerHTML = pauseIcon; }
            else { video.pause(); playBtn.innerHTML = playIcon; }
        });
        video.addEventListener('timeupdate', () => {
            if (video.duration) {
                progressFill.style.width = (video.currentTime / video.duration * 100) + '%';
                timeDisplay.textContent = this._formatTime(video.currentTime) + ' / ' + this._formatTime(video.duration);
            }
        });
        video.addEventListener('ended', () => { playBtn.innerHTML = playIcon; });
        progress.addEventListener('click', (e) => {
            e.stopPropagation();
            if (video.duration) {
                const rect = progress.getBoundingClientRect();
                video.currentTime = ((e.clientX - rect.left) / rect.width) * video.duration;
            }
        });
        fsBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            if (video.requestFullscreen) video.requestFullscreen();
            else if (video.webkitRequestFullscreen) video.webkitRequestFullscreen();
        });

        // Stop propagation to prevent flipbook drag
        container.addEventListener('mousedown', (e) => e.stopPropagation());
        container.addEventListener('touchstart', (e) => e.stopPropagation());

        this._activeMediaPlayers[annot.id] = { video, container };
        return container;
    }

    _createAudioPlayer(annot) {
        const src = this._resolveMediaSource(annot);
        if (!src) return this._createMediaPlaceholder(annot, 'Audio');

        const container = document.createElement('div');
        container.className = 'media-audio-container';

        const icon = document.createElement('div');
        icon.className = 'media-audio-icon';
        icon.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>';
        container.appendChild(icon);

        const audio = document.createElement('audio');
        audio.src = src;
        audio.preload = 'metadata';
        if (annot.mimeType) audio.type = annot.mimeType;

        const controls = document.createElement('div');
        controls.className = 'media-player-controls';

        const playBtn = document.createElement('button');
        playBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
        const pauseIcon = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>';
        const playIcon = playBtn.innerHTML;

        const progress = document.createElement('div');
        progress.className = 'media-progress';
        const progressFill = document.createElement('div');
        progressFill.className = 'media-progress-fill';
        progress.appendChild(progressFill);

        const timeDisplay = document.createElement('span');
        timeDisplay.className = 'media-time';
        timeDisplay.textContent = '0:00';

        controls.appendChild(playBtn);
        controls.appendChild(progress);
        controls.appendChild(timeDisplay);
        container.appendChild(controls);

        playBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            if (audio.paused) { audio.play(); playBtn.innerHTML = pauseIcon; }
            else { audio.pause(); playBtn.innerHTML = playIcon; }
        });
        audio.addEventListener('timeupdate', () => {
            if (audio.duration) {
                progressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
                timeDisplay.textContent = this._formatTime(audio.currentTime) + ' / ' + this._formatTime(audio.duration);
            }
        });
        audio.addEventListener('ended', () => { playBtn.innerHTML = playIcon; });
        progress.addEventListener('click', (e) => {
            e.stopPropagation();
            if (audio.duration) {
                const rect = progress.getBoundingClientRect();
                audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
            }
        });

        container.addEventListener('mousedown', (e) => e.stopPropagation());
        container.addEventListener('touchstart', (e) => e.stopPropagation());

        this._activeMediaPlayers[annot.id] = { audio, container };
        return container;
    }

    _create3DViewer(annot) {
        const format = annot.format || 'unknown';

        if (format === 'u3d' || format === 'prc' || format === 'unknown') {
            // Can't render U3D/PRC natively in browser.
            // The PDF already renders a static 2D snapshot of the 3D model,
            // so show a small corner badge instead of covering the whole area.
            const badge = document.createElement('div');
            badge.className = 'media-3d-badge';
            badge.innerHTML = `
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
                    <path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 002 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/>
                    <polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
                    <line x1="12" y1="22.08" x2="12" y2="12"/>
                </svg>
                <span>Load 3D Model</span>
            `;
            badge.title = 'Load an external glTF/GLB model to view in 3D';
            badge.addEventListener('click', (e) => {
                e.stopPropagation();
                this._promptExternal3DModel(annot);
            });
            badge.addEventListener('mousedown', (e) => e.stopPropagation());
            return badge;
        }

        // glTF/GLB: create a Three.js viewer container
        const container = document.createElement('div');
        container.className = 'viewer-3d-container';
        container.dataset.annotId = annot.id;

        const loading = document.createElement('div');
        loading.className = 'viewer-3d-loading';
        loading.textContent = t('error.loading3d');
        container.appendChild(loading);

        // Toolbar
        const toolbar = document.createElement('div');
        toolbar.className = 'viewer-3d-toolbar';

        const resetBtn = document.createElement('button');
        resetBtn.title = 'Reset view';
        resetBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>';

        const expandBtn = document.createElement('button');
        expandBtn.title = 'Fullscreen';
        expandBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>';

        toolbar.appendChild(resetBtn);
        toolbar.appendChild(expandBtn);
        container.appendChild(toolbar);

        container.addEventListener('mousedown', (e) => e.stopPropagation());
        container.addEventListener('touchstart', (e) => e.stopPropagation());
        container.addEventListener('wheel', (e) => e.stopPropagation());

        // Defer init until container has dimensions
        container._pendingInit = true;
        container._annot = annot;
        container._resetBtn = resetBtn;
        container._expandBtn = expandBtn;

        return container;
    }

    async _init3DViewerInContainer(container, annot) {
        if (!container._pendingInit) return;
        container._pendingInit = false;

        const width = container.clientWidth;
        const height = container.clientHeight;
        if (width < 10 || height < 10) { container._pendingInit = true; return; }

        try {
            const [gltfMod, orbitMod] = await Promise.all([loadGLTFLoader(), loadOrbitControls()]);
            const GLTFLoader = gltfMod.GLTFLoader;
            const OrbitControls = orbitMod.OrbitControls;

            const scene = new THREE.Scene();
            scene.background = new THREE.Color(0x1a1a24);

            // Lighting
            const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
            scene.add(ambientLight);
            const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
            dirLight.position.set(5, 10, 7);
            scene.add(dirLight);

            const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
            camera.position.set(0, 1, 3);

            const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
            renderer.setSize(width, height);
            renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

            const loadingEl = container.querySelector('.viewer-3d-loading');
            if (loadingEl) loadingEl.remove();
            container.insertBefore(renderer.domElement, container.firstChild);

            const controls = new OrbitControls(camera, renderer.domElement);
            controls.enableDamping = true;
            controls.dampingFactor = 0.1;

            // Load model
            const loader = new GLTFLoader();
            const src = this._resolve3DSource(annot);
            if (!src) {
                container.innerHTML = '<div class="viewer-3d-loading">No model source available</div>';
                return;
            }

            loader.load(src, (gltf) => {
                const model = gltf.scene;
                // Auto-center and scale
                const box = new THREE.Box3().setFromObject(model);
                const center = box.getCenter(new THREE.Vector3());
                const size = box.getSize(new THREE.Vector3());
                const maxDim = Math.max(size.x, size.y, size.z);
                const scale = 2 / maxDim;
                model.scale.setScalar(scale);
                model.position.sub(center.multiplyScalar(scale));
                scene.add(model);

                controls.target.set(0, 0, 0);
                camera.position.set(0, size.y * scale * 0.5, maxDim * scale * 2);
                controls.update();
            }, undefined, (err) => {
                console.error('Error loading 3D model:', err);
                const loadEl = document.createElement('div');
                loadEl.className = 'viewer-3d-loading';
                loadEl.textContent = t('error.3dModel');
                container.appendChild(loadEl);
            });

            // Animation loop
            let animId;
            const animate = () => {
                animId = requestAnimationFrame(animate);
                controls.update();
                renderer.render(scene, camera);
            };
            animate();

            // Store for cleanup and reset
            const viewer = { scene, camera, renderer, controls, animId, container };
            this._active3DViewers[annot.id] = viewer;

            // Reset button
            if (container._resetBtn) {
                container._resetBtn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    controls.reset();
                });
            }
            // Expand button
            if (container._expandBtn) {
                container._expandBtn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    if (container.requestFullscreen) container.requestFullscreen();
                    else if (container.webkitRequestFullscreen) container.webkitRequestFullscreen();
                });
            }

        } catch (err) {
            console.error('Error initializing 3D viewer:', err);
            container.innerHTML = '<div class="viewer-3d-loading">Error initializing 3D viewer</div>';
        }
    }

    _createRichMediaWidget(annot) {
        const rType = annot.richMediaType || 'unknown';

        if (rType === 'flash') {
            return this._createMediaPlaceholder(annot, 'Flash content (not supported)');
        }

        if (rType === 'html' && annot.url) {
            const container = document.createElement('div');
            container.className = 'richmedia-container';
            const iframe = document.createElement('iframe');
            iframe.src = annot.url;
            iframe.sandbox = 'allow-scripts allow-same-origin';
            iframe.loading = 'lazy';
            container.appendChild(iframe);
            container.addEventListener('mousedown', (e) => e.stopPropagation());
            return container;
        }

        if (rType === 'video') {
            annot.mediaType = 'video';
            return this._createVideoPlayer(annot);
        }
        if (rType === 'audio') {
            annot.mediaType = 'audio';
            return this._createAudioPlayer(annot);
        }

        return this._createMediaPlaceholder(annot, 'Rich Media');
    }

    _createMediaPlaceholder(annot, label) {
        const placeholder = document.createElement('div');
        placeholder.className = 'media-placeholder';

        let iconSvg;
        if (label.includes('Audio')) {
            iconSvg = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>';
        } else if (label.includes('3D')) {
            iconSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 002 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/></svg>';
        } else {
            iconSvg = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
        }

        placeholder.innerHTML = `
            <div class="media-placeholder-icon">${iconSvg}</div>
            <div class="media-placeholder-label">${label}<br>Click to load external file</div>
        `;

        placeholder.addEventListener('click', (e) => {
            e.stopPropagation();
            this._promptExternalMedia(annot);
        });
        placeholder.addEventListener('mousedown', (e) => e.stopPropagation());
        return placeholder;
    }

    repositionMediaOverlay() {
        if (!this._mediaElements || !this._mediaElements.length) return;
        if (this.isVerticalViewer || this.isHorizontalViewer) return;

        const overlay = document.getElementById('pdfMediaOverlay');
        if (overlay) overlay.style.opacity = this.isAnimating ? '0' : '1';

        const boundsCache = {};
        for (const { el, annot, pageNum } of this._mediaElements) {
            if (!boundsCache[pageNum]) {
                boundsCache[pageNum] = this.getPageScreenBounds(pageNum);
            }
            const bounds = boundsCache[pageNum];
            if (!bounds) {
                el.style.display = 'none';
                continue;
            }

            el.style.display = '';
            el.style.left = (bounds.left + annot.x * bounds.width) + 'px';
            el.style.top = (bounds.top + annot.y * bounds.height) + 'px';
            el.style.width = (annot.w * bounds.width) + 'px';
            el.style.height = (annot.h * bounds.height) + 'px';

            // Deferred 3D viewer init once we have valid dimensions
            const viewer3d = el.querySelector('.viewer-3d-container');
            if (viewer3d && viewer3d._pendingInit) {
                this._init3DViewerInContainer(viewer3d, viewer3d._annot);
            }
        }
    }

    _destroyActiveMedia() {
        // Pause and clean up video/audio players
        for (const id of Object.keys(this._activeMediaPlayers)) {
            const player = this._activeMediaPlayers[id];
            if (player.video) { player.video.pause(); player.video.src = ''; }
            if (player.audio) { player.audio.pause(); player.audio.src = ''; }
        }
        this._activeMediaPlayers = {};

        // Dispose 3D viewers
        for (const id of Object.keys(this._active3DViewers)) {
            const viewer = this._active3DViewers[id];
            if (viewer.animId) cancelAnimationFrame(viewer.animId);
            if (viewer.renderer) {
                viewer.renderer.dispose();
                viewer.renderer.forceContextLoss();
            }
            if (viewer.controls) viewer.controls.dispose();
            if (viewer.scene) {
                viewer.scene.traverse((obj) => {
                    if (obj.geometry) obj.geometry.dispose();
                    if (obj.material) {
                        if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
                        else obj.material.dispose();
                    }
                });
            }
        }
        this._active3DViewers = {};

        // Revoke blob URLs
        if (this._mediaBlobUrls) {
            this._mediaBlobUrls.forEach(url => URL.revokeObjectURL(url));
            this._mediaBlobUrls = [];
        }

        this._mediaElements = [];
    }

    _pauseNonVisibleMedia() {
        const visiblePages = this.getVisiblePageNumbers();
        for (const { annot } of this._mediaElements) {
            const player = this._activeMediaPlayers[annot.id];
            if (!player) continue;
            if (!visiblePages.includes(annot.pageNum)) {
                if (player.video && !player.video.paused) player.video.pause();
                if (player.audio && !player.audio.paused) player.audio.pause();
            }
        }
    }

    _resolveMediaSource(annot) {
        if (annot.url) return annot.url;
        if (annot.data) {
            if (!this._mediaBlobUrls) this._mediaBlobUrls = [];
            const url = URL.createObjectURL(annot.data);
            this._mediaBlobUrls.push(url);
            return url;
        }
        return null;
    }

    _resolve3DSource(annot) {
        if (annot.url) return annot.url;
        if (annot.data) {
            if (!this._mediaBlobUrls) this._mediaBlobUrls = [];
            const url = URL.createObjectURL(annot.data);
            this._mediaBlobUrls.push(url);
            return url;
        }
        return null;
    }

    _formatTime(seconds) {
        const m = Math.floor(seconds / 60);
        const s = Math.floor(seconds % 60);
        return m + ':' + (s < 10 ? '0' : '') + s;
    }

    _promptExternalMedia(annot) {
        const url = prompt('Enter URL for the media file:');
        if (url && url.trim()) {
            annot.url = url.trim();
            this.buildMediaOverlay();
        }
    }

    _promptExternal3DModel(annot) {
        const url = prompt('Enter URL for a glTF/GLB 3D model:');
        if (url && url.trim()) {
            annot.url = url.trim();
            annot.format = 'gltf';
            this.buildMediaOverlay();
        }
    }

    createViewerPageMedia(pageNum, wrapper) {
        const annotations = this.pageMediaAnnotations[pageNum - 1];
        if (!annotations || !annotations.length) return;

        const mediaContainer = document.createElement('div');
        mediaContainer.className = 'viewer-page-media';

        for (const annot of annotations) {
            const el = document.createElement('div');
            el.className = 'media-element';
            el.style.left = (annot.x * 100) + '%';
            el.style.top = (annot.y * 100) + '%';
            el.style.width = (annot.w * 100) + '%';
            el.style.height = (annot.h * 100) + '%';

            const content = this._createMediaElement(annot);
            if (content) {
                el.appendChild(content);
                mediaContainer.appendChild(el);
            }
        }

        wrapper.appendChild(mediaContainer);
    }

    // ==========================================
    // Page Video Embeds (User-Configured Videos)
    // ==========================================

        buildPageVideoOverlay() {}
        repositionPageVideoOverlay() {}
        _destroyPageVideos() {}
        updatePageVideos() {}
        updatePageVideoForCurrentPage() {}
    createViewerPageVideo() {}
    createViewerPageNotes() {}
    createViewerConfigHotspots() {}
    populateFormViewer() {}
    rotatePages() {}

    // True when the next forward flip in spread mode would only reveal a blank
    // trailing face — i.e. the next unflipped sheet is the last one and its back
    // page doesn't exist (odd page count). Used to bound both flipPage() and the
    // next/last button disabled-state so you can't land on a white page.
    _nextFlipRevealsBlank() {
        if (this.forceSinglePage || !this.pages || this.pages.length === 0) return false;
        const idx = this.pages.findIndex(p => !p.flipped);
        return idx === this.pages.length - 1 && this.pages[idx].backPageNum > this.numPages;
    }

    flipPage(direction) {

        // Vertical viewer: scroll to prev/next page
        if (this.isVerticalViewer) {
            const current = this._verticalCurrentPage || 1;
            const target = direction === 'next'
                ? Math.min(current + 1, this.numPages)
                : Math.max(current - 1, 1);
            if (target !== current) {
                this.goToPage(target);
            }
            return;
        }

        // Horizontal viewer: scroll to prev/next page
        if (this.isHorizontalViewer) {
            const current = this._horizontalCurrentPage || 1;
            const target = direction === 'next'
                ? Math.min(current + 1, this.numPages)
                : Math.max(current - 1, 1);
            if (target !== current) {
                this.goToPage(target);
            }
            return;
        }

        // Form viewer: scroll to prev/next page
        if (this.isFormViewer) {
            const current = this._formCurrentPage || 1;
            const target = direction === 'next'
                ? Math.min(current + 1, this.numPages)
                : Math.max(current - 1, 1);
            if (target !== current) {
                this.goToPage(target);
            }
            return;
        }

        if (this.isAnimating) return;

        // Handle single page mode differently
        if (this.forceSinglePage) {
            this.flipSinglePage(direction);
            return;
        }

        // Non-classic flip effects (slide, fade, fold, hard)
        const _flipEffect = CONFIG.flipEffect || 'realistic';
        // _effectFlipPage is a PRO_ONLY method (stripped from the Lite engine).
        // Guard the call so a non-free effect value can never throw; fall through
        // to the realistic 3D flip when the implementation isn't present.
        if (_flipEffect !== 'realistic' && _flipEffect !== 'hard'
            && typeof this._effectFlipPage === 'function') {
            this._effectFlipPage(direction, _flipEffect);
            return;
        }

        // 2D CSS fallback: use CSS transitions
        if (this.is2D) {
            this._2d_flipPage(direction, _flipEffect);
            return;
        }

        let pageIndex;
        if (direction === 'next') {
            // Don't flip past the last real page
            const currentPage = this.getCurrentPageNumber();
            if (currentPage >= this.numPages) return;
            // Even page mode spread: if the right page is the last page, don't flip further
            if (this.isEvenPageMode && !this.forceSinglePage && currentPage + 1 >= this.numPages) return;

            // Spread mode: the final sheet can have a blank back face when the
            // page count is odd. Flipping it would only reveal a trailing white
            // page, so stop here. (Single-page mode is bounded by the check above.)
            if (this._nextFlipRevealsBlank()) return;

            // Find next unflipped page
            pageIndex = this.pages.findIndex(p => !p.flipped);
            if (pageIndex === -1) return;
        } else {
            // Don't flip before minimum flipped state
            const currentFlippedCount = this.pages.filter(p => p.flipped).length;
            if (currentFlippedCount <= this.minFlipped) return;
            // Find last flipped page
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) {
                    pageIndex = i;
                    break;
                }
            }
            if (pageIndex === undefined) return;
        }

        // Priority-render the target spread if the loader hasn't reached it yet.
        // No-op if already cached, so cheap to call on every flip.
        const currentFlipped = this.pages.filter(p => p.flipped).length;
        const targetFlipped = direction === 'next' ? currentFlipped + 1 : currentFlipped - 1;
        this._prefetchJumpTargets(targetFlipped);

        this.isAnimating = true;

        // Hide hardcover during flip so it doesn't occlude page content
        // Emit flip start event
        if (this._tncfb) {
            const fromPage = this.getCurrentPageNumber();
            const step = this.forceSinglePage ? 1 : 2;
            const toPage = direction === 'next' ? fromPage + step : Math.max(1, fromPage - step);
            this._tncfb.emit('page:flip:start', { fromPage, toPage, direction });
        }

        // Cancel ALL lift animations on all pages before flipping
        this.pages.forEach(p => {
            if (p.liftAnimationId) {
                cancelAnimationFrame(p.liftAnimationId);
                p.liftAnimationId = null;
            }
            if (p.currentLiftAmount > 0) {
                this.resetPageCurl(p);
            }
        });

        // Clear any hover state and reset lifted page
        if (this.hoveredPage) {
            this.resetPageCurl(this.hoveredPage);
            this.hoveredPage = null;
        }
        this.hoveredCorner = null;

        // Play flip sound
        this.playFlipSound();

        const pageData = this.pages[pageIndex];

        // Reset the page about to flip to ensure clean state
        if (pageData.currentLiftAmount > 0) {
            this.resetPageCurl(pageData);
        }

        // Create flip shadow on the page being revealed
        this._createFlipShadow(pageData, direction);

        // Store the starting z and calculate target z
        const startZ = pageData.pivot.position.z;
        const targetZ = 0.02; // Final z position for visible page

        const targetRotation = this._flipTarget(direction);
        const startRotation = pageData.pivot.rotation.y;

        // Reduced motion: skip animation, jump to final state
        if (typeof a11y !== 'undefined' && a11y && a11y.prefersReducedMotion) {
            pageData.pivot.rotation.y = targetRotation;
            pageData.pivot.position.y = 0;
            pageData.pivot.position.z = targetZ;
            this.resetPageCurl(pageData);
            this._removeFlipShadow(pageData);
            pageData.flipped = direction === 'next';
            this.isAnimating = false;
            this.updatePageZOrder();
            const fc = this.pages.filter(p => p.flipped).length;
            this.currentSpread = Math.floor(fc / 2) + 1;
            this.updatePageIndicator();
            this.updateViewMode();
            this.updateHardcoverVisibility();
            this.updatePeelPositions();
            this._recheckPeelHover();
            return;
        }

        const startTime = Date.now();

        const animateFlip = () => {
            const elapsed = Date.now() - startTime;
            const progress = Math.min(elapsed / CONFIG.flipDuration, 1);

            const isHardFlip = CONFIG.flipEffect === 'hard';

            // Easing: hard uses linear-ish with sharp deceleration, classic uses cubic ease-out
            const eased = isHardFlip
                ? (progress < 0.8 ? progress * 1.15 : 0.92 + (progress - 0.8) * 0.4) // fast then settle
                : 1 - Math.pow(1 - progress, 3);

            // Rotate the pivot
            pageData.pivot.rotation.y = startRotation + (targetRotation - startRotation) * Math.min(eased, 1);

            if (isHardFlip) {
                // Hard flip: no curl, minimal Z-lift, no Y-bob, heavier shadow
                const zLift = Math.sin(progress * Math.PI) * 0.03; // barely lifts
                pageData.pivot.position.z = startZ + (targetZ - startZ) * Math.min(eased, 1) + zLift;
                pageData.pivot.position.y = 0; // no bounce

                // Overshoot bounce at end (last 15%)
                if (progress > 0.85) {
                    const bounceProgress = (progress - 0.85) / 0.15;
                    const bounce = Math.sin(bounceProgress * Math.PI) * 0.02;
                    pageData.pivot.rotation.y += bounce * (direction === 'next' ? 1 : -1);
                }

                this._updateFlipShadow(pageData, progress, direction);
            } else {
                // Classic flip: full curl deformation + lifts
                const zLift = Math.sin(progress * Math.PI) * 0.1;
                pageData.pivot.position.z = startZ + (targetZ - startZ) * eased + zLift;

                const curlAmount = Math.sin(progress * Math.PI);
                this.applyCurl(pageData, curlAmount * 0.8, progress, direction);

                pageData.pivot.position.y = Math.sin(progress * Math.PI) * 0.15;

                this._updateFlipShadow(pageData, progress, direction);
            }

            if (progress < 1) {
                requestAnimationFrame(animateFlip);
            } else {
                // Reset to flat
                this.resetPageCurl(pageData);
                this._removeFlipShadow(pageData);
                pageData.pivot.position.y = 0;
                pageData.pivot.position.z = targetZ;
                pageData.flipped = direction === 'next';
                this.isAnimating = false;

                // Update z-ordering for ALL pages to ensure proper display
                this.updatePageZOrder();

                // Update spread count
                const flippedCount = this.pages.filter(p => p.flipped).length;
                this.currentSpread = Math.floor(flippedCount / 2) + 1;
                this.updatePageIndicator();

                // Update view mode (single/double page)
                this.updateViewMode();

                // Update hardcover visibility for first/last spread
                this.updateHardcoverVisibility();

                // Update peel positions for new page state
                this.updatePeelPositions();

                // Re-check hover after flip: mouse may still be inside a peel zone
                // but no new mouseenter fires since it never left
                this._recheckPeelHover();

                // Emit flip end event
                if (this._tncfb) {
                    this._tncfb.emit('page:flip:end', { page: this.getCurrentPageNumber() });
                }
            }
        };

        animateFlip();
    }

    updatePageZOrder() {
        if (this.is2D) { this._2d_updateZOrder(); return; }
        // Find the last flipped page (visible on left) and first unflipped page (visible on right)
        let lastFlippedIndex = -1;
        let firstUnflippedIndex = -1;

        for (let i = 0; i < this.pages.length; i++) {
            if (this.pages[i].flipped) {
                lastFlippedIndex = i;
            } else if (firstUnflippedIndex === -1) {
                firstUnflippedIndex = i;
            }
        }

        // Position all pages
        for (let i = 0; i < this.pages.length; i++) {
            const page = this.pages[i];
            if (page.flipped) {
                // Flipped pages: last flipped at front (z=0.02), others behind
                if (i === lastFlippedIndex) {
                    page.pivot.position.z = 0.02;
                } else {
                    page.pivot.position.z = -0.01 - (lastFlippedIndex - i) * CONFIG.pageDepth;
                }
            } else {
                // Unflipped pages: first unflipped at front (z=0.02), others behind
                if (i === firstUnflippedIndex) {
                    page.pivot.position.z = 0.02;
                } else {
                    page.pivot.position.z = -0.01 - (i - firstUnflippedIndex) * CONFIG.pageDepth;
                }
            }
        }

    }

    goToPage(pageNum, options = {}) {
        const { silent = false, jump = false } = options;


        // Vertical viewer: scroll to the page
        if (this.isVerticalViewer) {
            const targetEl = this._findViewerPageElement('vertical', pageNum);
            if (targetEl) {
                targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
                this._verticalCurrentPage = pageNum;
                this.updatePageIndicator();
            }
            return;
        }

        // Horizontal viewer: scroll to the page
        if (this.isHorizontalViewer) {
            const targetEl = this._findViewerPageElement('horizontal', pageNum);
            if (targetEl) {
                targetEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
                this._horizontalCurrentPage = pageNum;
                this.updatePageIndicator();
            }
            return;
        }

        // Form viewer: scroll to the page
        if (this.isFormViewer) {
            const targetEl = document.getElementById(`form-page-${pageNum}`);
            if (targetEl) {
                targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
                this._formCurrentPage = pageNum;
                this.updatePageIndicator();
            }
            return;
        }

        if (this.isAnimating) return;

        // In single page mode, use goToSinglePage
        if (this.forceSinglePage) {
            this.goToSinglePage(pageNum, silent);
            return;
        }

        // 2D CSS fallback: jump to target spread instantly
        if (this.is2D) {
            this._2d_goToPage(pageNum, silent);
            return;
        }

        // Clamp to valid page range
        pageNum = Math.max(1, Math.min(pageNum, this.numPages));

        // Convert page number to spread index (number of sheets to flip)
        // Odd mode: Page 1 = 0 flipped (cover), Page 2-3 = 1 flipped, etc.
        // Even mode: Page 1-2 = 1 flipped, Page 3-4 = 2 flipped, etc.
        const targetFlipped = Math.floor((pageNum + (this.isEvenPageMode ? 1 : 0)) / 2);

        const currentFlipped = this.pages.filter(p => p.flipped).length;

        if (targetFlipped === currentFlipped) return;

        const pagesToFlip = Math.abs(targetFlipped - currentFlipped);
        const direction = targetFlipped > currentFlipped ? 'next' : 'prev';

        // Priority-render the target spread for any jump that skips the loader's gradual fill.
        if (jump || pagesToFlip > 3) {
            this._prefetchJumpTargets(targetFlipped);
        }

        // Single-transition jump (deep linking, programmatic skips)
        if (jump) {
            this._jumpWithTransition(targetFlipped, direction);
            return;
        }

        // For very large jumps, skip animation entirely
        if (pagesToFlip > 5) {
            this.jumpToFlippedState(targetFlipped, silent);
            return;
        }

        // For moderate jumps (more than 3 pages), use faster animation
        const fastMode = pagesToFlip > 3;

        this.animateToTarget(targetFlipped, direction, fastMode, silent);
    }

    animateToTarget(targetFlipped, direction, fastMode = false, silent = false) {
        const currentFlipped = this.pages.filter(p => p.flipped).length;

        if (currentFlipped === targetFlipped) {
            this.isAnimating = false;
            this.updatePageZOrder();
            this.updateViewMode();
            this.updateMinimap();
            return;
        }

        this.isAnimating = true;
        // In fast mode, center book so flips don't appear offset
        if (fastMode) {
            this.book.position.x = 0;
        }

        // Cancel ALL lift animations on all pages
        this.pages.forEach(p => {
            if (p.liftAnimationId) {
                cancelAnimationFrame(p.liftAnimationId);
                p.liftAnimationId = null;
            }
            if (p.currentLiftAmount > 0) {
                this.resetPageCurl(p);
            }
        });

        // Clear any hover state and reset lifted page
        if (this.hoveredPage) {
            this.resetPageCurl(this.hoveredPage);
            this.hoveredPage = null;
        }
        this.hoveredCorner = null;

        let pageIndex;
        if (direction === 'next') {
            pageIndex = this.pages.findIndex(p => !p.flipped);
        } else {
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) {
                    pageIndex = i;
                    break;
                }
            }
        }

        if (pageIndex === undefined || pageIndex === -1) {
            this.isAnimating = false;
            this.updateViewMode();
            this.updateMinimap();
            return;
        }

        // Play flip sound (unless silent mode)
        if (!silent) this.playFlipSound();

        const pageData = this.pages[pageIndex];

        // Reset the page about to flip to ensure clean state
        if (pageData.currentLiftAmount > 0) {
            this.resetPageCurl(pageData);
        }

        // Create flip shadow on the page being revealed
        this._createFlipShadow(pageData, direction);

        // Store the starting z and calculate target z
        const startZ = pageData.pivot.position.z;
        const targetZ = 0.02;

        const targetRotation = this._flipTarget(direction);
        const startRotation = pageData.pivot.rotation.y;
        const startTime = Date.now();

        // Faster duration for multi-page navigation
        const duration = fastMode ? CONFIG.fastFlipDuration : CONFIG.flipDuration;

        const animateFlip = () => {
            const elapsed = Date.now() - startTime;
            const progress = Math.min(elapsed / duration, 1);

            const eased = 1 - Math.pow(1 - progress, 3);

            pageData.pivot.rotation.y = startRotation + (targetRotation - startRotation) * eased;

            // Animate z-position
            const zLift = Math.sin(progress * Math.PI) * (fastMode ? 0.05 : 0.1);
            pageData.pivot.position.z = startZ + (targetZ - startZ) * eased + zLift;

            // Reduced curl for fast mode
            const curlAmount = Math.sin(progress * Math.PI);
            this.applyCurl(pageData, curlAmount * (fastMode ? 0.4 : 0.8), progress, direction);

            pageData.pivot.position.y = Math.sin(progress * Math.PI) * (fastMode ? 0.08 : 0.15);

            // Update flip shadow
            this._updateFlipShadow(pageData, progress, direction);

            if (progress < 1) {
                requestAnimationFrame(animateFlip);
            } else {
                this.resetPageCurl(pageData);
                this._removeFlipShadow(pageData);
                pageData.pivot.position.y = 0;
                pageData.pivot.position.z = targetZ;
                pageData.flipped = direction === 'next';

                const flippedCount = this.pages.filter(p => p.flipped).length;
                this.currentSpread = Math.floor(flippedCount / 2) + 1;
                this.updatePageIndicator();

                // Continue to next page or finish
                if (flippedCount !== targetFlipped) {
                    // Small delay between flips for visual clarity
                    setTimeout(() => {
                        this.animateToTarget(targetFlipped, direction, fastMode, silent);
                    }, fastMode ? 10 : 100);
                } else {
                    this.isAnimating = false;
                    // Update z-ordering for ALL pages to ensure proper display
                    this.updatePageZOrder();
                    this.updateViewMode();
                    this.updateHardcoverVisibility();
                    // Reset pan and update minimap after page change
                    this.panX = 0;
                    this.panY = 0;
                    this.updateCameraPosition();
                    this.updateMinimap();
                    // Pause media on pages no longer visible
                    this._pauseNonVisibleMedia();
                }
            }
        };

        animateFlip();
    }

    goToFirst() {
        if (this.isVerticalViewer || this.isHorizontalViewer) {
            this.goToPage(1);
            return;
        }
        const target = this.forceSinglePage
            ? Math.max(Math.floor((1 + (this.isEvenPageMode ? 1 : 0)) / 2), this.minFlipped)
            : this.minFlipped;
        const current = this.pages.filter(p => p.flipped).length;
        if (target === current && (!this.forceSinglePage || this.currentSinglePage === 1)) return;
        if (this.forceSinglePage) this.currentSinglePage = 1;
        this._prefetchJumpTargets(target);
        this._jumpWithTransition(target, 'prev');
    }

    goToLast() {
        if (this.isVerticalViewer || this.isHorizontalViewer) {
            this.goToPage(this.numPages);
            return;
        }
        const target = Math.floor((this.numPages + (this.isEvenPageMode ? 1 : 0)) / 2);
        const current = this.pages.filter(p => p.flipped).length;
        if (target === current && (!this.forceSinglePage || this.currentSinglePage === this.numPages)) return;
        if (this.forceSinglePage) this.currentSinglePage = this.numPages;
        this._prefetchJumpTargets(target);
        this._jumpWithTransition(target, 'next');
    }

    /**
     * Jump to a target flipped state with a single smooth transition.
     * Slides the current view out, swaps all sheets instantly, then slides the new view in.
     */
    _jumpWithTransition(targetFlipped, direction) {
        if (this.isAnimating) return;
        this.isAnimating = true;
        this.playFlipSound();

        const duration = CONFIG.fastFlipDuration || 400;

        if (this.is2D) {
            // 2D: use slide transition
            const bookEl = this._2d_bookEl;
            if (!bookEl) { this.isAnimating = false; return; }

            const slideDistance = window.innerWidth * 0.6;
            const sign = direction === 'next' ? -1 : 1;

            bookEl.classList.add('tncfb-sliding');
            const halfDuration = duration / 2;
            const startTime = performance.now();
            const ease = (t) => t < 1 ? 1 - Math.pow(1 - t, 3) : 1;

            const animateOut = (now) => {
                const elapsed = now - startTime;
                const progress = Math.min(elapsed / halfDuration, 1);
                const offset = sign * slideDistance * ease(progress);
                bookEl.style.transform = `translate(calc(-50% + ${offset}px), -50%)`;

                if (progress < 1) {
                    requestAnimationFrame(animateOut);
                } else {
                    // Swap all sheets instantly while off-screen
                    for (let i = 0; i < this.pages.length; i++) {
                        const shouldBeFlipped = i < targetFlipped;
                        this.pages[i].flipped = shouldBeFlipped;
                        if (shouldBeFlipped) {
                            this.pages[i].sheetEl.classList.add('flipped');
                        } else {
                            this.pages[i].sheetEl.classList.remove('flipped');
                        }
                    }
                    this._2d_updateZOrder();
                    // Update classes FIRST so _2d_onResize knows the correct state
                    const fc = this.pages.filter(p => p.flipped).length;
                    const atStart = !this.isEvenPageMode && fc === this.minFlipped;
                    const atEnd = fc === this.pages.length;
                    const isSingleView = this.forceSinglePage;
                    bookEl.classList.toggle('at-start', atStart && !isSingleView);
                    bookEl.classList.toggle('at-end', atEnd && !isSingleView);
                    bookEl.classList.toggle('spread-open', !isSingleView && !atStart && !atEnd);
                    // Update single page visibility if in single page mode
                    if (this.forceSinglePage) {
                        this._2d_updateSinglePageVisibility();
                    }
                    // Now resize book for the new state while still off-screen
                    this._2d_onResize();

                    // Slide in from opposite side
                    bookEl.style.transform = `translate(calc(-50% + ${-sign * slideDistance}px), -50%)`;
                    const enterStart = performance.now();
                    const animateIn = (now2) => {
                        const elapsed2 = now2 - enterStart;
                        const progress2 = Math.min(elapsed2 / halfDuration, 1);
                        const offset2 = -sign * slideDistance * (1 - ease(progress2));
                        bookEl.style.transform = `translate(calc(-50% + ${offset2}px), -50%)`;

                        if (progress2 < 1) {
                            requestAnimationFrame(animateIn);
                        } else {
                            // Clean finish: set final transform directly, then remove sliding
                            const rot = this._2d_currentRotation || 0;
                            const scale = this.zoomLevel ? this.zoomLevel / 100 : 1;
                            bookEl.style.transform = `translate(-50%, -50%) scale(${scale}) rotate(${rot}deg)`;
                            bookEl.classList.remove('tncfb-sliding');
                            this.currentSpread = Math.floor(fc / 2) + 1;
                            this.isAnimating = false;
                            this.updatePageIndicator();
                        }
                    };
                    requestAnimationFrame(animateIn);
                }
            };
            requestAnimationFrame(animateOut);
        } else if (this.forceSinglePage) {
            // Single-page mode (first/last + deep-link jumps): show the target
            // page instantly via the ghost flipper, consistent with arrows and
            // page jumps. The spread slide below is double-page only — running it
            // in single mode left a real sheet AND the flipper visible (two
            // pages) and positioned the book off-centre.
            this._singlePageJumpTo(this.currentSinglePage, true);
            this.isAnimating = false;
        } else {
            // 3D: slide the book out, swap, slide back
            const book = this.book;
            const startX = book.position.x;
            const slideDistance = CONFIG.pageWidth * 2.5;
            const slideOut = direction === 'next' ? -slideDistance : slideDistance;
            let swapped = false;
            const startTime = Date.now();

            const animate = () => {
                const elapsed = Date.now() - startTime;
                const progress = Math.min(elapsed / duration, 1);
                const eased = progress < 0.5
                    ? 2 * progress * progress
                    : 1 - Math.pow(-2 * progress + 2, 2) / 2;

                if (progress < 0.5) {
                    book.position.x = startX + slideOut * (eased * 2);
                } else {
                    if (!swapped) {
                        swapped = true;
                        // Swap all pages instantly
                        for (let i = 0; i < this.pages.length; i++) {
                            const shouldBeFlipped = i < targetFlipped;
                            this.pages[i].flipped = shouldBeFlipped;
                            this.pages[i].pivot.rotation.y = shouldBeFlipped ? -Math.PI : 0;
                        }
                        this.updatePageZOrder();
                        this.updatePageVisibility();
                        book.position.x = startX - slideOut;
                    }
                    const inProgress = (eased - 0.5) * 2;
                    book.position.x = startX - slideOut * (1 - inProgress);
                }

                if (progress < 1) {
                    requestAnimationFrame(animate);
                } else {
                    this.isAnimating = false;
                    // Re-evaluate spread↔single layout (solo cover/back-cover pages
                    // collapse the book to single-page mode). Without this the fast
                    // jump leaves the book sized for a spread with one empty side.
                    this.updateViewMode();
                    book.position.x = this.getTargetBookPosition();
                    this.targetBookX = book.position.x;
                    this.updatePageIndicator();
                    this.updateHardcoverVisibility();
                    this.updateSpiralPosition();
                }
            };
            requestAnimationFrame(animate);
        }
    }

    // Navigate to a specific flipped state (number of sheets flipped)
    goToFlippedState(targetFlipped) {
        if (this.isAnimating) return;

        const currentFlipped = this.pages.filter(p => p.flipped).length;
        if (targetFlipped === currentFlipped) return;

        // 2D mode: instantly flip/unflip sheets
        if (this.is2D) {
            for (let i = 0; i < this.pages.length; i++) {
                const shouldBeFlipped = i < targetFlipped;
                this.pages[i].flipped = shouldBeFlipped;
                if (shouldBeFlipped) {
                    this.pages[i].sheetEl.classList.add('flipped');
                } else {
                    this.pages[i].sheetEl.classList.remove('flipped');
                }
            }
            this._2d_updateZOrder();
            const fc = this.pages.filter(p => p.flipped).length;
            this.currentSpread = Math.floor(fc / 2) + 1;
            this.updatePageIndicator();
            this._2d_updateLayout();
            return;
        }

        const pagesToFlip = Math.abs(targetFlipped - currentFlipped);

        // For very large jumps, skip animation entirely
        if (pagesToFlip > 5) {
            this.jumpToFlippedState(targetFlipped);
            return;
        }

        const direction = targetFlipped > currentFlipped ? 'next' : 'prev';
        const fastMode = pagesToFlip > 3;

        this.animateToTarget(targetFlipped, direction, fastMode);
    }

    // Rapid flip: animate through all sheets but much faster
    jumpToFlippedState(targetFlipped, silent = false) {
        const currentFlipped = this.pages.filter(p => p.flipped).length;
        const direction = targetFlipped > currentFlipped ? 'next' : 'prev';

        // 2D mode: use rapid CSS flip animation
        if (this.is2D) {
            this._2d_animateToTarget(targetFlipped, direction, true, silent);
            return;
        }

        if (this.forceSinglePage) {
            // Ghost-flipper jump (consistent with arrows / page jumps); the old
            // real-sheet animation left the flipper visible (two pages) + off-centre.
            this._singlePageJumpTo(this.currentSinglePage, silent);
        } else {
            this.animateToTarget(targetFlipped, direction, true, silent);
        }
    }

    // Navigate to a specific single page
    goToSinglePage(pageNum, silent = false) {
        if (pageNum < 1 || pageNum > this.numPages) return;
        if (this.isAnimating) return;

        // Calculate required flipped sheets for target page
        const targetFlipped = Math.max(
            Math.floor((pageNum + (this.isEvenPageMode ? 1 : 0)) / 2),
            this.minFlipped
        );

        const currentFlipped = this.pages.filter(p => p.flipped).length;

        const prevSinglePage = this.currentSinglePage;
        this.currentSinglePage = pageNum;

        // Priority-render the target page if the loader hasn't reached it yet.
        if (Math.abs(pageNum - (prevSinglePage || 1)) > 3) {
            this._prefetchJumpTargets(targetFlipped);
        }

        // 2D mode: instant navigation
        if (this.is2D) {
            if (!silent) this.playFlipSound();
            this.pages.forEach((p, i) => {
                const shouldBeFlipped = i < targetFlipped;
                if (shouldBeFlipped && !p.flipped) {
                    p.flipped = true;
                    if (p.sheetEl) p.sheetEl.classList.add('flipped');
                } else if (!shouldBeFlipped && p.flipped) {
                    p.flipped = false;
                    if (p.sheetEl) p.sheetEl.classList.remove('flipped');
                }
            });
            this._2d_updateSinglePageVisibility();
            this._2d_updateZOrder();
            this._2d_updateLayout();
            this.updatePageIndicator();
            return;
        }

        // 3D single-page navigation uses the ghost flipper — the same system the
        // single-page toggle and the prev/next arrows use. The older real-sheet
        // path (animateSinglePageFlip / animateSinglePageSlide) never hid the
        // flipper meshes (so a page jump showed two stacked pages) and used a
        // parity-based book offset that fought the flipper's centering, pushing
        // the page off to the side.
        const delta = pageNum - (prevSinglePage || 1);
        if (Math.abs(delta) === 1) {
            // Adjacent page: animate the hinge flip. _singlePageGhostFlip reads
            // this.currentSinglePage as the *source* page, so restore it (it was
            // set to the target above for prefetch + indicator).
            this.currentSinglePage = prevSinglePage;
            this._singlePageGhostFlip(pageNum, delta > 0 ? 'next' : 'prev', silent);
        } else {
            // Page-number / thumbnail / TOC jump: show the target page instantly,
            // centered, with all real sheets and the inactive flipper hidden.
            this._singlePageJumpTo(pageNum, silent);
        }
    }

    // Animate to target flipped state for single page mode
    animateToTargetSinglePage(targetFlipped, direction, fastMode = false) {
        const currentFlipped = this.pages.filter(p => p.flipped).length;

        if (currentFlipped === targetFlipped) {
            this.isAnimating = false;
            this.updatePageZOrder();
            this.updateViewMode();
            this.updatePageIndicator();
            this.updateMinimap();
            return;
        }

        this.isAnimating = true;

        // Cancel lift animations
        this.pages.forEach(p => {
            if (p.liftAnimationId) {
                cancelAnimationFrame(p.liftAnimationId);
                p.liftAnimationId = null;
            }
            if (p.currentLiftAmount > 0) {
                this.resetPageCurl(p);
            }
        });

        if (this.hoveredPage) {
            this.resetPageCurl(this.hoveredPage);
            this.hoveredPage = null;
        }
        this.hoveredCorner = null;

        let pageIndex;
        if (direction === 'next') {
            pageIndex = this.pages.findIndex(p => !p.flipped);
        } else {
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) {
                    pageIndex = i;
                    break;
                }
            }
        }

        if (pageIndex === undefined || pageIndex === -1) {
            this.isAnimating = false;
            this.updateViewMode();
            this.updateMinimap();
            return;
        }

        // Only show the flipping page, hide all others
        const flippingPage = this.pages[pageIndex];
        this.pages.forEach((p, idx) => {
            if (idx === pageIndex) {
                p.pivot.visible = true;
                p.pivot.scale.set(1, 1, 1);
                if (p.savedZ !== undefined) {
                    p.pivot.position.z = p.savedZ;
                    delete p.savedZ;
                }
            } else {
                p.pivot.visible = false;
                p.pivot.scale.set(0, 0, 0);
            }
        });

        this.playFlipSound();

        const pageData = flippingPage;
        if (pageData.currentLiftAmount > 0) {
            this.resetPageCurl(pageData);
        }

        const startZ = pageData.pivot.position.z;
        const targetZ = 0.02;
        const targetRotation = this._flipTarget(direction);
        const startRotation = pageData.pivot.rotation.y;
        const startTime = Date.now();
        const duration = fastMode ? CONFIG.fastFlipDuration : CONFIG.flipDuration;

        const animateFlip = () => {
            const elapsed = Date.now() - startTime;
            const progress = Math.min(elapsed / duration, 1);
            const eased = 1 - Math.pow(1 - progress, 3);

            pageData.pivot.rotation.y = startRotation + (targetRotation - startRotation) * eased;

            const zLift = Math.sin(progress * Math.PI) * (fastMode ? 0.05 : 0.1);
            pageData.pivot.position.z = startZ + (targetZ - startZ) * eased + zLift;

            const curlAmount = Math.sin(progress * Math.PI);
            this.applyCurl(pageData, curlAmount * (fastMode ? 0.4 : 0.8), progress, direction);

            pageData.pivot.position.y = Math.sin(progress * Math.PI) * (fastMode ? 0.08 : 0.15);

            if (progress < 1) {
                requestAnimationFrame(animateFlip);
            } else {
                this.resetPageCurl(pageData);
                pageData.pivot.position.y = 0;
                pageData.pivot.position.z = targetZ;
                pageData.flipped = direction === 'next';

                const flippedCount = this.pages.filter(p => p.flipped).length;

                if (flippedCount !== targetFlipped) {
                    setTimeout(() => {
                        this.animateToTargetSinglePage(targetFlipped, direction, fastMode);
                    }, fastMode ? 10 : 100);
                } else {
                    this.isAnimating = false;

                    // Update book position for single page mode
                    const halfPageWidth = CONFIG.pageWidth / 2;
                    const isTargetOdd = this.currentSinglePage % 2 === 1;
                    this.book.position.x = isTargetOdd ? -halfPageWidth : halfPageWidth;
                    this.targetBookX = this.book.position.x;

                    this.updatePageZOrder();
                    this.updatePageVisibility();
                    this.updatePageIndicator();
                    this.updatePeelPositions();
                    this.panX = 0;
                    this.panY = 0;
                    this.updateCameraPosition();
                    this.updateMinimap();
                }
            }
        };

        animateFlip();
    }

    updatePageIndicator() {
        const indicator = document.getElementById('pageIndicator');
        if (!this.numPages) {
            indicator.textContent = t('page.loading');
            return;
        }

        // Evict GPU textures for pages outside the window (large books only)
        if (typeof this._updateTextureWindow === 'function') {
            try { this._updateTextureWindow(); } catch (e) { /* defensive */ }
        }

        this.updateMinimap();
        // Refit watermark to the new visible page bounds (covers 2D mode where the
        // animate loop doesn't tick).
        if (typeof this.repositionWatermark === 'function') {
            this.repositionWatermark();
        }

        // Sync prev/next/first/last disabled state. Done here (before mode-specific
        // early returns) so scroll-driven viewers (vertical/horizontal/form) update too.
        {
            const navPage = this.getCurrentPageNumber();
            const atLastPage = navPage >= this.numPages ||
                (this.isEvenPageMode && !this.forceSinglePage &&
                 !this.isVerticalViewer && !this.isHorizontalViewer && !this.isFormViewer &&
                 navPage + 1 >= this.numPages) ||
                (!this.isVerticalViewer && !this.isHorizontalViewer && !this.isFormViewer &&
                 this._nextFlipRevealsBlank());
            const prevBtn = document.getElementById('prevBtn');
            const nextBtn = document.getElementById('nextBtn');
            const firstBtn = document.getElementById('firstBtn');
            const lastBtn = document.getElementById('lastBtn');
            const _previewKeepNavEnabled = false;
            if (prevBtn) prevBtn.disabled = navPage <= 1;
            if (firstBtn) firstBtn.disabled = navPage <= 1;
            if (nextBtn) nextBtn.disabled = atLastPage && !_previewKeepNavEnabled;
            if (lastBtn) lastBtn.disabled = atLastPage && !_previewKeepNavEnabled;

        }

        // Helper to format page with optional section name
        const formatPage = (pageNum) => {
            let text = t('page.indicator', { current: pageNum, total: this.numPages });
            // Show section name if multiple PDFs are loaded
            if (this.pdfSections && this.pdfSections.length > 1) {
                const section = this.getPageSection(pageNum);
                if (section && section.name) {
                    text += ` • ${section.name}`;
                }
            }
            return text;
        };

        // Vertical viewer mode
        if (this.isVerticalViewer) {
            const pageNum = this._verticalCurrentPage || 1;
            indicator.textContent = formatPage(pageNum);
            if (this.isReadingMode) this.updateReadingIndicator();
            return;
        }

        // Horizontal viewer mode
        if (this.isHorizontalViewer) {
            const pageNum = this._horizontalCurrentPage || 1;
            indicator.textContent = formatPage(pageNum);
            if (this.isReadingMode) this.updateReadingIndicator();
            return;
        }

        // Form viewer mode
        if (this.isFormViewer) {
            const pageNum = this._formCurrentPage || 1;
            indicator.textContent = formatPage(pageNum);
            return;
        }

        // Single page mode shows just the current page
        if (this.forceSinglePage) {
            indicator.textContent = formatPage(this.currentSinglePage);
            this.updateReadingIndicator();
            this.buildLinkOverlay();
            this.buildMediaOverlay();
            return;
        }

        const flippedCount = this.pages.filter(p => p.flipped).length;

        // Check if single page view
        const isAtStart = flippedCount === this.minFlipped;
        const isAtEnd = flippedCount === this.pages.length;

        if (isAtStart) {
            if (this.isEvenPageMode && this.numPages > 1) {
                let text = `1-2 / ${this.numPages}`;
                indicator.textContent = text;
            } else {
                // Cover page only
                indicator.textContent = formatPage(1);
            }
        } else if (isAtEnd) {
            // Back cover only
            indicator.textContent = formatPage(this.numPages);
        } else {
            // Double page view - show range with optional section info
            const leftPage = this.isEvenPageMode ? flippedCount * 2 - 1 : flippedCount * 2;
            const rightPage = Math.min(leftPage + 1, this.numPages);
            let text = `${leftPage}-${rightPage} / ${this.numPages}`;
            // Show section if multiple PDFs and both pages are in same section
            if (this.pdfSections && this.pdfSections.length > 1) {
                const leftSection = this.getPageSection(leftPage);
                const rightSection = this.getPageSection(rightPage);
                if (leftSection && leftSection.name) {
                    if (leftSection === rightSection) {
                        text += ` • ${leftSection.name}`;
                    } else {
                        text += ` • ${leftSection.name} / ${rightSection.name}`;
                    }
                }
            }
            indicator.textContent = text;
        }

        this.updateReadingIndicator();

        // Rebuild link overlay for new visible pages
        this.buildLinkOverlay();

        // Rebuild media overlay for new visible pages
        this.buildMediaOverlay();


        // Track page view in analytics
        const currentPage = isAtStart ? 1 : (isAtEnd ? this.numPages : (this.isEvenPageMode ? flippedCount * 2 - 1 : flippedCount * 2));
        if (this._lastTrackedPage !== currentPage) {
            this._lastTrackedPage = currentPage;
            analytics.trackPageView(currentPage, this.numPages);
        }

        // Announce page change to screen readers
        if (typeof a11y !== 'undefined' && a11y) {
            a11y.announce(indicator.textContent.replace('/', 'of'));
        }

        // Update URL hash for deep linking
        this._updateDeepLink();

        // Emit page:change event
        const page = this.getCurrentPageNumber();
        if (this._tncfb && this._lastEmittedPage !== page) {
            const previousPage = this._lastEmittedPage || 0;
            this._lastEmittedPage = page;
            this._tncfb.emit('page:change', { page, previousPage, totalPages: this.numPages });
        }

        // Recalculate auto-zoom when spread state changes (single ↔ double)
        if (!this.is2D && this.camera && (this.zoomMode === 'auto' || this.zoomMode === null)) {
            const fitZoom = this.calculateAutoZoom();
            const targetZoom = Math.min(fitZoom, 100);
            if (Math.abs(targetZoom - this.zoomLevel) > 2) {
                this.zoomLevel = targetZoom;
                this.zoomMode = 'auto';
                this.updateCameraPosition();
                this.updateZoomDisplay();
            }
        }

        // Update nav arrow disabled states
        const navPage = this.getCurrentPageNumber();
        const atLastPage = navPage >= this.numPages ||
            (this.isEvenPageMode && !this.forceSinglePage && navPage + 1 >= this.numPages) ||
            (!this.isVerticalViewer && !this.isHorizontalViewer && !this.isFormViewer &&
             this._nextFlipRevealsBlank());
        const prevBtn = document.getElementById('prevBtn');
        const nextBtn = document.getElementById('nextBtn');
        const firstBtn = document.getElementById('firstBtn');
        const lastBtn = document.getElementById('lastBtn');
        const _previewKeepNavEnabled = false;
        if (prevBtn) prevBtn.disabled = navPage <= 1;
        if (firstBtn) firstBtn.disabled = navPage <= 1;
        if (nextBtn) nextBtn.disabled = atLastPage && !_previewKeepNavEnabled;
        if (lastBtn) lastBtn.disabled = atLastPage && !_previewKeepNavEnabled;
    }

    // RTL direction helper: maps visual direction to logical direction
    // In LTR: left=prev, right=next
    // In RTL: left=next, right=prev
    visualToLogical(visualDirection) {
        if (!this.isRTL) return visualDirection;
        return visualDirection === 'next' ? 'prev' : 'next';
    }

    // Get flip rotation target
    _flipTarget(direction) {
        return direction === 'next' ? -Math.PI : 0;
    }

    toggleRTL() {
        this.isRTL = !this.isRTL;
        document.body.classList.toggle('rtl-mode', this.isRTL);
        // Reposition book
        this.book.position.x = this.getTargetBookPosition();
        this.targetBookX = this.book.position.x;
        // Update spiral position
        this.updateSpiralPosition();
        // Update hardcover
        this.updateHardcoverVisibility();
        // Refresh page indicator
        this.updatePageIndicator();
        // Update More menu state
    }

    // Get current page number for all viewer modes
    getCurrentPageNumber() {
        if (this.isVerticalViewer) return this._verticalCurrentPage || 1;
        if (this.isHorizontalViewer) return this._horizontalCurrentPage || 1;
        if (this.isFormViewer) return this._formCurrentPage || 1;
        if (this.forceSinglePage) return this.currentSinglePage || 1;

        const flippedCount = this.pages.filter(p => p.flipped).length;
        if (flippedCount === this.minFlipped) return 1;
        if (flippedCount === this.pages.length) return this.numPages;
        return this.isEvenPageMode ? flippedCount * 2 - 1 : flippedCount * 2; // Left page of spread
    }

    // Determine if current view should be single or double page
    shouldBeSinglePage() {
        // If force single page mode is enabled, always return true
        if (this.forceSinglePage) return true;

        const flippedCount = this.pages.filter(p => p.flipped).length;
        // Even mode: start is a spread (not single page)
        if (this.isEvenPageMode) {
            const isAtEnd = flippedCount === this.pages.length;
            // End is single page only if odd numPages (last page alone)
            return isAtEnd && this.numPages % 2 === 1;
        }
        // Odd mode: single page at cover or back cover
        const isAtStart = flippedCount === 0;
        const isAtEnd = flippedCount === this.pages.length;
        return isAtStart || isAtEnd;
    }

    // Get the target X position for the book based on view mode
    getTargetBookPosition() {
        const flippedCount = this.pages.filter(p => p.flipped).length;
        const halfPageWidth = CONFIG.pageWidth / 2;

        let x;
        if (this.forceSinglePage) {
            if (this.isEvenPageMode) {
                x = this.currentSinglePage % 2 === 1 ? halfPageWidth : -halfPageWidth;
            } else {
                x = this.currentSinglePage % 2 === 1 ? -halfPageWidth : halfPageWidth;
            }
        } else if (flippedCount === this.minFlipped) {
            x = this.isEvenPageMode ? 0 : -halfPageWidth;
        } else if (flippedCount === this.pages.length) {
            if (this.isEvenPageMode && this.numPages % 2 === 0) { x = 0; }
            else { x = halfPageWidth; }
        } else {
            x = 0;
        }

        return x;
    }

    // Update view mode and animate transition
    updateViewMode() {
        if (this.is2D) {
            this._2d_updateLayout();
            return;
        }
        const shouldBeSingle = this.shouldBeSinglePage();
        const newMode = shouldBeSingle ? 'single' : 'double';
        const targetX = this.getTargetBookPosition();

        if (this.targetBookX !== targetX) {
            this.targetBookX = targetX;
            this.animateBookPosition(targetX);
        }

        this.viewMode = newMode;

        // Update spine shadow visibility
        if (this.spineShadow) {
            this.spineShadow.visible = !shouldBeSingle;
        }

        // Update page visibility for single page mode
        this.updatePageVisibility();

        // Update drop shadow and crease line for single/double page
        this.updateDropShadowLayout();
    }

    // Update which pages are visible (for single page mode)
    updatePageVisibility() {
        if (!this.pages.length) return;

        // 2D mode
        if (this.is2D) {
            if (this.forceSinglePage) {
                this._2d_updateSinglePageVisibility();
            } else {
                this.pages.forEach(p => {
                    if (p.sheetEl) p.sheetEl.style.display = '';
                });
                this._2d_updateZOrder();
            }
            return;
        }

        if (this.forceSinglePage) {
            // Single page mode - show ONLY the page that should be visible
            const isOddPage = this.currentSinglePage % 2 === 1;

            // Find the visible left page (last flipped) and right page (first unflipped)
            let lastFlippedIndex = -1;
            let firstUnflippedIndex = -1;

            for (let i = 0; i < this.pages.length; i++) {
                if (this.pages[i].flipped) {
                    lastFlippedIndex = i;
                } else if (firstUnflippedIndex === -1) {
                    firstUnflippedIndex = i;
                }
            }

            // Determine which single sheet should be visible
            let visibleSheetIndex = isOddPage ? firstUnflippedIndex : lastFlippedIndex;

            // Safety fallback - ensure we always have a visible sheet
            if (visibleSheetIndex === -1) {
                // If odd page but no unflipped sheets, show last sheet
                // If even page but no flipped sheets, show first sheet
                visibleSheetIndex = isOddPage
                    ? this.pages.length - 1  // Last sheet if all flipped
                    : 0;  // First sheet if none flipped
            }

            // Hide ALL pages except the one that should be visible
            // Use visibility, position, AND scale to ensure hiding works
            this.pages.forEach((pageData, index) => {
                if (index === visibleSheetIndex) {
                    pageData.pivot.visible = true;
                    pageData.pivot.scale.set(1, 1, 1);
                    // Restore original z position
                    if (pageData.savedZ !== undefined) {
                        pageData.pivot.position.z = pageData.savedZ;
                        delete pageData.savedZ;
                    }
                    pageData.pivot.position.z = 0.02;
                } else {
                    pageData.pivot.visible = false;
                    pageData.pivot.scale.set(0, 0, 0); // Scale to zero
                    // Save and move hidden pages far back
                    if (pageData.savedZ === undefined) {
                        pageData.savedZ = pageData.pivot.position.z;
                    }
                    pageData.pivot.position.z = -100;
                }
            });
        } else {
            // Double page mode - show all pages and restore positions
            this.pages.forEach(pageData => {
                pageData.pivot.visible = true;
                pageData.pivot.scale.set(1, 1, 1);
                // Restore original z position if hidden
                if (pageData.savedZ !== undefined) {
                    pageData.pivot.position.z = pageData.savedZ;
                    delete pageData.savedZ;
                }
            });
            // Re-apply proper z-ordering
            this.updatePageZOrder();
        }
    }

    // Animate book position with smooth transition
    animateBookPosition(targetX, callback = null) {
        if (this.is2D || this.isTransitioning) return;

        this.isTransitioning = true;
        const startX = this.book.position.x;
        const startTime = Date.now();
        const duration = 400;

        const animatePosition = () => {
            const elapsed = Date.now() - startTime;
            const progress = Math.min(elapsed / duration, 1);

            // Smooth easing
            const eased = 1 - Math.pow(1 - progress, 3);

            this.book.position.x = startX + (targetX - startX) * eased;

            if (progress < 1) {
                requestAnimationFrame(animatePosition);
            } else {
                this.book.position.x = targetX;
                this.isTransitioning = false;
                if (callback) callback();
            }
        };

        animatePosition();
    }

    // Animate book position with callback when done
    animateBookPositionWithCallback(targetX, callback) {
        if (this.isTransitioning) {
            // If already transitioning, just set the callback for later
            setTimeout(() => {
                if (callback) callback();
            }, 400);
            return;
        }

        this.isTransitioning = true;
        const startX = this.book.position.x;
        const startTime = Date.now();
        const duration = 400;

        const animatePosition = () => {
            const elapsed = Date.now() - startTime;
            const progress = Math.min(elapsed / duration, 1);

            const eased = 1 - Math.pow(1 - progress, 3);
            this.book.position.x = startX + (targetX - startX) * eased;

            if (progress < 1) {
                requestAnimationFrame(animatePosition);
            } else {
                this.book.position.x = targetX;
                this.isTransitioning = false;
                if (callback) callback();
            }
        };

        animatePosition();
    }

    // Animate book tilt (skew view)
    animateBookTilt(targetTilt) {
        if (this.is2D) return;
        const startTilt = this.book.rotation.x;
        const startTime = Date.now();
        const duration = 400;

        const animate = () => {
            const elapsed = Date.now() - startTime;
            const progress = Math.min(elapsed / duration, 1);
            const eased = 1 - Math.pow(1 - progress, 3);

            this.book.rotation.x = startTilt + (targetTilt - startTilt) * eased;

            if (progress < 1) {
                requestAnimationFrame(animate);
            } else {
                this.book.rotation.x = targetTilt;
            }
        };

        animate();
    }

    animateBookRotation() {}

    setupControls() {
        document.getElementById('prevBtn').addEventListener('click', () => this.flipPage('prev'));
        document.getElementById('nextBtn').addEventListener('click', () => this.flipPage('next'));
        document.getElementById('firstBtn').addEventListener('click', () => this.goToFirst());
        document.getElementById('lastBtn').addEventListener('click', () => this.goToLast());

        // Keyboard (RTL-aware: arrow keys map to visual direction)
        document.addEventListener('keydown', (e) => {
            if (e.key === 'ArrowLeft') this.flipPage(this.visualToLogical('prev'));
            if (e.key === 'ArrowRight') this.flipPage(this.visualToLogical('next'));
            if (e.key === 'Home') this.goToFirst();
            if (e.key === 'End') this.goToLast();
            if (e.key === 'r' || e.key === 'R') {
                // Don't trigger if typing in an input
                if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
                this.toggleReadingMode();
            }
        });

        // Mouse wheel zoom
        // Standalone viewer (full page): plain scroll zooms
        // Embedded (iframe/shortcode): require Ctrl/Cmd + scroll
        this._isStandalone = (() => {
            try {
                // Standalone if body only has #app and no parent iframe
                const isTopLevel = window === window.top;
                const bodyChildren = document.body.children;
                const hasOnlyApp = bodyChildren.length <= 3; // app + possible script tags
                return isTopLevel && hasOnlyApp;
            } catch (e) {
                return false; // cross-origin iframe
            }
        })();

        this.container.addEventListener('wheel', (e) => {
            if (!this._isStandalone && !e.ctrlKey && !e.metaKey) {
                // Show hint tooltip
                this._showScrollZoomHint();
                return; // Let page scroll normally
            }
            e.preventDefault();
            const delta = e.deltaY > 0 ? -10 : 10;
            this.setZoom(this.zoomLevel + delta);
        }, { passive: false });

        // Mouse drag panning — gated entirely on hand tool. With hand tool
        // off, click-drag is reserved for text selection (or no-op if the
        // text layer isn't built); the user has to enable hand tool to pan,
        // even when zoomed in.
        this.container.addEventListener('mousedown', (e) => {
            if (!this.handToolActive) return;
            if (e.button !== 0) return; // Only left click

            this.isPanning = true;
            this.panStartX = e.clientX;
            this.panStartY = e.clientY;
            this.lastPanX = this.panX;
            this.lastPanY = this.panY;
            this.container.style.cursor = 'grabbing';
        });

        document.addEventListener('mousemove', (e) => {
            if (!this.isPanning) return;

            const dx = e.clientX - this.panStartX;
            const dy = e.clientY - this.panStartY;

            // Scale movement based on zoom level
            const scale = 0.01 / (this.zoomLevel / 100);

            this.panX = this.lastPanX - dx * scale;
            this.panY = this.lastPanY + dy * scale;

            // Clamp pan values
            const maxPan = (this.zoomLevel - 100) / 50;
            this.panX = Math.max(-maxPan, Math.min(maxPan, this.panX));
            this.panY = Math.max(-maxPan * 0.7, Math.min(maxPan * 0.7, this.panY));

            this.updateCameraPosition();
            this.updateMinimap();
        });

        document.addEventListener('mouseup', () => {
            if (this.isPanning) {
                this.isPanning = false;
                this.container.style.cursor = this.handToolActive ? 'grab' : '';
            }
        });

        // Click to place note (when in note-add mode)
        this.container.addEventListener('click', (e) => {
            if (this._isAddingNote) {
                this.handleNotePageClick(e);
            }
        });

        // Minimap click to pan
        const minimapContainer = document.getElementById('minimapContainer');
        minimapContainer.addEventListener('click', (e) => {
            this.handleMinimapClick(e);
        });

        // Touch swipe support for mobile
        let touchStartX = 0;
        let touchStartY = 0;
        let touchStartTime = 0;
        let isTouchPanning = false;

        this.container.addEventListener('touchstart', (e) => {
            if (e.touches.length === 1) {
                touchStartX = e.touches[0].clientX;
                touchStartY = e.touches[0].clientY;
                touchStartTime = Date.now();

                // Check if we should pan (when zoomed in)
                if (this.zoomLevel > 100) {
                    isTouchPanning = true;
                    this.lastPanX = this.panX;
                    this.lastPanY = this.panY;
                }
            }
        }, { passive: true });

        this.container.addEventListener('touchmove', (e) => {
            if (e.touches.length === 1 && isTouchPanning && this.zoomLevel > 100) {
                const dx = e.touches[0].clientX - touchStartX;
                const dy = e.touches[0].clientY - touchStartY;

                const scale = 0.01 / (this.zoomLevel / 100);
                this.panX = this.lastPanX + dx * scale;
                this.panY = this.lastPanY - dy * scale;

                const maxPan = (this.zoomLevel - 100) / 50;
                this.panX = Math.max(-maxPan, Math.min(maxPan, this.panX));
                this.panY = Math.max(-maxPan * 0.7, Math.min(maxPan * 0.7, this.panY));

                this.updateCameraPosition();
                this.updateMinimap();
            }
        }, { passive: true });

        this.container.addEventListener('touchend', (e) => {
            if (isTouchPanning) {
                isTouchPanning = false;
                return;
            }

            const touchEndX = e.changedTouches[0].clientX;
            const touchEndY = e.changedTouches[0].clientY;
            const touchEndTime = Date.now();

            const deltaX = touchEndX - touchStartX;
            const deltaY = touchEndY - touchStartY;
            const deltaTime = touchEndTime - touchStartTime;

            // Minimum swipe distance and maximum time for a swipe
            const minSwipeDistance = 50;
            const maxSwipeTime = 300;

            // Only trigger swipe if horizontal movement is greater than vertical
            if (Math.abs(deltaX) > Math.abs(deltaY) &&
                Math.abs(deltaX) > minSwipeDistance &&
                deltaTime < maxSwipeTime &&
                this.zoomLevel <= 100) {

                if (deltaX > 0) {
                    this.flipPage(this.visualToLogical('prev'));
                } else {
                    this.flipPage(this.visualToLogical('next'));
                }
            }
        }, { passive: true });

        // Pinch to zoom support
        let initialPinchDistance = 0;
        let initialZoom = 100;

        this.container.addEventListener('touchstart', (e) => {
            if (e.touches.length === 2) {
                initialPinchDistance = Math.hypot(
                    e.touches[0].clientX - e.touches[1].clientX,
                    e.touches[0].clientY - e.touches[1].clientY
                );
                initialZoom = this.zoomLevel;
            }
        }, { passive: true });

        this.container.addEventListener('touchmove', (e) => {
            if (e.touches.length === 2) {
                const currentDistance = Math.hypot(
                    e.touches[0].clientX - e.touches[1].clientX,
                    e.touches[0].clientY - e.touches[1].clientY
                );

                const scale = currentDistance / initialPinchDistance;
                const newZoom = Math.round(initialZoom * scale);
                this.setZoom(newZoom);
            }
        }, { passive: true });

        // Corner hover and drag for 3D page flip effect
        const cornerMap = {
            'peelTopRight': 'top-right',
            'peelBottomRight': 'bottom-right',
            'peelTopLeft': 'top-left',
            'peelBottomLeft': 'bottom-left'
        };

        this._cornerMap = cornerMap;
        this._mousePos = { x: -1, y: -1, valid: false };
        this._cornerEnabled = (id) => {
            const el = document.getElementById(id);
            return !!(el && el.style.display !== 'none');
        };

        // Coordinate-based corner hit-test: returns {corner, isRight} or null.
        this._findCornerAt = (x, y) => {
            if (this.is2D) return null;
            const corners = this.getProjectedPageCorners();
            if (!corners) return null;
            const RADIUS = 120;
            let best = null, bestDist = RADIUS;
            const enabledMap = {
                'top-left':     this._cornerEnabled('peelTopLeft'),
                'top-right':    this._cornerEnabled('peelTopRight'),
                'bottom-left':  this._cornerEnabled('peelBottomLeft'),
                'bottom-right': this._cornerEnabled('peelBottomRight'),
            };
            for (const [corner, pt] of Object.entries(corners)) {
                if (!enabledMap[corner]) continue;
                const d = Math.hypot(x - pt.x, y - pt.y);
                if (d < bestDist) {
                    bestDist = d;
                    best = { corner, isRight: corner.endsWith('-right') };
                }
            }
            return best;
        };

        // Drag/click to flip — coordinate-based, on the main container so the
        // hit zone matches the actual projected page corner, not the AABB DOM box.
        // Ignore corner-drag on UI elements (toolbar, popups, player, sidebars, nav arrows)
        const _isUIClick = (target) => {
            // NOTE: the actual toolbar is `.bottom-toolbar` (+ `.form-toolbar`);
            // there is no `.toolbar` element, so non-button toolbar areas (the
            // page-indicator div, dividers) used to fall through to the corner
            // flip — clicking the page number triggered a page turn.
            return target.closest('.bottom-toolbar, .bottom-toolbar-landmark, .form-toolbar, .page-indicator, .more-popup, .audio-player, .sidebar, .share-popup, .nav-arrow, .peel-zone, button, a, select, input, [role="button"]');
        };

        document.addEventListener('mousedown', (e) => {
            if (this.isAnimating || this.isDragging || this.dragPending) return;
            if (e.button !== 0) return;
            if (_isUIClick(e.target)) return;
            const hit = this._findCornerAt(e.clientX, e.clientY);
            if (!hit) return;
            e.preventDefault();
            this.initPendingDrag(e.clientX, e.clientY, hit.isRight);
        });

        document.addEventListener('touchstart', (e) => {
            if (this.isAnimating || this.isDragging || this.dragPending) return;
            if (e.touches.length !== 1) return;
            if (_isUIClick(e.target)) return;
            const t = e.touches[0];
            const hit = this._findCornerAt(t.clientX, t.clientY);
            if (!hit) return;
            e.preventDefault();
            this.initPendingDrag(t.clientX, t.clientY, hit.isRight);
        }, { passive: false });

        // Coordinate-based hover detection: more reliable than mouseenter/mouseleave,
        // which can be intercepted by overlapping elements (toolbars, side controls)
        // and don't fire if conditions (isAnimating) clear while the cursor is stationary.
        this._updateCornerHover = () => {
            if (this.is2D || !this._mousePos.valid) return;
            if (this.isDragging || this.dragPending) return;

            const x = this._mousePos.x, y = this._mousePos.y;
            const corners = this.getProjectedPageCorners();
            if (!corners) return;

            // Per-corner enabled state — respects the existing show/hide logic
            // applied to the peel DOM elements in updatePeelPositions().
            const enabled = {
                'top-left':     this._cornerEnabled('peelTopLeft'),
                'top-right':    this._cornerEnabled('peelTopRight'),
                'bottom-left':  this._cornerEnabled('peelBottomLeft'),
                'bottom-right': this._cornerEnabled('peelBottomRight'),
            };

            const RADIUS = 120;
            let activeCorner = null;
            let activeIsRight = false;
            let bestDist = RADIUS;

            for (const [corner, pt] of Object.entries(corners)) {
                if (!enabled[corner]) continue;
                const dx = x - pt.x, dy = y - pt.y;
                const d = Math.hypot(dx, dy);
                if (d < bestDist) {
                    bestDist = d;
                    activeCorner = corner;
                    activeIsRight = corner.endsWith('-right');
                }
            }

            if (activeCorner) {
                if (this.isAnimating) return; // wait — next mousemove or tick will retry
                const pageData = this._hoverCurlTarget(activeIsRight);
                if (!pageData) return;
                if (this.hoveredPage === pageData && this.hoveredCorner === activeCorner) return;

                // Switching from another page/corner — reset previous first
                if (this.hoveredPage && this.hoveredPage !== pageData) {
                    const prev = this.hoveredPage;
                    if (prev.liftAnimationId) {
                        cancelAnimationFrame(prev.liftAnimationId);
                        prev.liftAnimationId = null;
                    }
                    this.animateCornerLift(prev, this.hoveredCorner, 0);
                }
                if (pageData.liftAnimationId) {
                    cancelAnimationFrame(pageData.liftAnimationId);
                    pageData.liftAnimationId = null;
                }
                this.hoveredPage = pageData;
                this.hoveredCorner = activeCorner;
                this.animateCornerLift(pageData, activeCorner, 1);
            } else if (this.hoveredPage) {
                const leavingPage = this.hoveredPage;
                const leavingCorner = this.hoveredCorner;
                this.hoveredPage = null;
                this.hoveredCorner = null;
                if (leavingPage.liftAnimationId) {
                    cancelAnimationFrame(leavingPage.liftAnimationId);
                    leavingPage.liftAnimationId = null;
                }
                if (this.isAnimating) {
                    this.resetPageCurl(leavingPage);
                    leavingPage.currentLiftAmount = 0;
                } else {
                    this.animateCornerLift(leavingPage, leavingCorner, 0);
                }
            }
        };

        // Global mouse/touch move and end for drag flip
        document.addEventListener('mousemove', (e) => {
            this._mousePos.x = e.clientX;
            this._mousePos.y = e.clientY;
            this._mousePos.valid = true;
            if (this.dragPending) {
                this.checkDragThreshold(e.clientX, e.clientY);
            }
            if (this.isDragging) {
                this.updateDragFlip(e.clientX, e.clientY);
            } else {
                this._updateCornerHover();
            }
        });

        document.addEventListener('mouseleave', () => {
            this._mousePos.valid = false;
            if (this.hoveredPage && !this.isDragging && !this.dragPending) {
                const leavingPage = this.hoveredPage;
                const leavingCorner = this.hoveredCorner;
                this.hoveredPage = null;
                this.hoveredCorner = null;
                if (leavingPage.liftAnimationId) {
                    cancelAnimationFrame(leavingPage.liftAnimationId);
                    leavingPage.liftAnimationId = null;
                }
                this.animateCornerLift(leavingPage, leavingCorner, 0);
            }
        });

        // Re-check hover state periodically so peel triggers after isAnimating clears
        // even if the cursor is stationary over a corner.
        setInterval(() => {
            if (!this.isDragging && !this.dragPending && !this.isAnimating) {
                this._updateCornerHover();
            }
        }, 100);

        document.addEventListener('mouseup', () => {
            if (this.dragPending) {
                // Didn't move enough - treat as click
                this.handleCornerClick();
            } else if (this.isDragging) {
                this.endDragFlip();
            }
        });

        document.addEventListener('touchmove', (e) => {
            if (this.dragPending && e.touches.length === 1) {
                this.checkDragThreshold(e.touches[0].clientX, e.touches[0].clientY);
            }
            if (this.isDragging && e.touches.length === 1) {
                this.updateDragFlip(e.touches[0].clientX, e.touches[0].clientY);
            }
        }, { passive: true });

        document.addEventListener('touchend', () => {
            if (this.dragPending) {
                // Didn't move enough - treat as tap
                this.handleCornerClick();
            } else if (this.isDragging) {
                this.endDragFlip();
            }
        });
    }

    // Re-check if mouse is still hovering a peel element after an animation completes.
    // Without this, mouseenter won't re-fire because the cursor never left.
    _recheckPeelHover() {
        if (this.is2D || this.isAnimating || this.isDragging || this.hoveredPage) return;

        const cornerMap = {
            'peelTopRight': { corner: 'top-right', isRight: true },
            'peelBottomRight': { corner: 'bottom-right', isRight: true },
            'peelTopLeft': { corner: 'top-left', isRight: false },
            'peelBottomLeft': { corner: 'bottom-left', isRight: false }
        };

        for (const [id, info] of Object.entries(cornerMap)) {
            const el = document.getElementById(id);
            if (el && el.matches(':hover')) {
                const pageData = this._hoverCurlTarget(info.isRight);
                if (pageData) {
                    this.hoveredPage = pageData;
                    this.hoveredCorner = info.corner;
                    this.animateCornerLift(pageData, info.corner, 1);
                }
                break;
            }
        }
    }

    // Get the page that should lift when hovering
    // Which page the hover edge-curl should be applied to. getHoverPage decides
    // whether a peel is allowed at all (navigation bounds) and, in spread mode,
    // returns the real sheet to curl. In single-page mode the visible page is the
    // ghost flipper (the real sheets are hidden), so the curl must target the
    // active flipper or the peel would silently deform an invisible sheet.
    _hoverCurlTarget(isRightSide) {
        const pageData = this.getHoverPage(isRightSide);
        if (!pageData) return null;
        if (this.forceSinglePage && this._singlePageFlipperReady && typeof this._spActive === 'function') {
            const active = this._spActive();
            if (active) return active;
        }
        return pageData;
    }

    getHoverPage(isRightSide) {
        const flippedCount = this.pages.filter(p => p.flipped).length;
        const isAtStart = flippedCount === this.minFlipped;
        const navPage = this.getCurrentPageNumber();
        const isAtEnd = flippedCount === this.pages.length ||
            (this.isEvenPageMode && !this.forceSinglePage && navPage + 1 >= this.numPages);

        // In single page mode, return the visible page for either side
        // This allows both corners to be clickable for navigation
        if (this.forceSinglePage) {
            const isOddPage = this.currentSinglePage % 2 === 1;

            // Check navigation bounds (RTL reverses: right=prev, left=next)
            const isNext = this.isRTL ? !isRightSide : isRightSide;
            if (isNext && this.currentSinglePage >= this.numPages) return null;
            if (!isNext && this.currentSinglePage <= 1) return null;

            // Return the currently visible page
            if (isOddPage) {
                // Odd page - return first unflipped (visible on right)
                const pageIndex = this.pages.findIndex(p => !p.flipped);
                return pageIndex >= 0 ? this.pages[pageIndex] : null;
            } else {
                // Even page - return last flipped (visible on left)
                for (let i = this.pages.length - 1; i >= 0; i--) {
                    if (this.pages[i].flipped) {
                        return this.pages[i];
                    }
                }
                return null;
            }
        }

        // Standard double page mode
        if (isAtStart || isAtEnd) {
            // First/last spread: only one page visible.
            // Check if the action this peel triggers is valid.
            const isNext = this.isRTL ? !isRightSide : isRightSide;
            if (isNext && isAtEnd) return null;
            if (!isNext && isAtStart) return null;

            // Return the only visible page
            if (isAtStart) {
                // Right page visible (first unflipped)
                const pageIndex = this.pages.findIndex(p => !p.flipped);
                return pageIndex >= 0 ? this.pages[pageIndex] : null;
            } else {
                // Left page visible (last flipped)
                for (let i = this.pages.length - 1; i >= 0; i--) {
                    if (this.pages[i].flipped) return this.pages[i];
                }
                return null;
            }
        }

        // Middle spreads: return the page on the physical side being hovered.
        if (isRightSide) {
            const pageIndex = this.pages.findIndex(p => !p.flipped);
            return pageIndex >= 0 ? this.pages[pageIndex] : null;
        } else {
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) return this.pages[i];
            }
            return null;
        }
    }

    // Initialize pending drag - waits for movement threshold before starting actual drag
    initPendingDrag(x, y, isRightSide) {
        if (this.isAnimating || this.isDragging || this.dragPending) return;

        const pageData = this.getHoverPage(isRightSide);
        if (!pageData) return;

        this.dragPending = true;
        this.dragStartX = x;
        this.dragStartY = y;
        this.pendingDragPage = pageData;
        this.pendingDragIsRight = isRightSide;

        // Clear hover state when starting potential drag
        if (this.hoveredPage) {
            this.resetPageCurl(this.hoveredPage);
            this.hoveredPage = null;
            this.hoveredCorner = null;
        }
    }

    // Check if mouse has moved enough to start actual drag
    checkDragThreshold(x, y) {
        if (!this.dragPending) return;

        const dx = Math.abs(x - this.dragStartX);
        const dy = Math.abs(y - this.dragStartY);
        const distance = Math.sqrt(dx * dx + dy * dy);

        if (distance >= this.dragThreshold) {
            // Exceeded threshold - start actual drag
            this.dragPending = false;
            this.startDragFlip(this.dragStartX, this.dragStartY, this.pendingDragIsRight);
            // Update with current position
            this.updateDragFlip(x, y);
        }
    }

    // Handle corner click (when released without dragging)
    handleCornerClick() {
        if (!this.dragPending) return;

        const isRightSide = this.pendingDragIsRight;
        this.dragPending = false;
        this.pendingDragPage = null;
        this.pendingDragIsRight = null;

        // Trigger page flip (RTL-aware: right side is visual 'next')
        this.flipPage(this.visualToLogical(isRightSide ? 'next' : 'prev'));
    }

    // Start drag flip
    startDragFlip(x, y, isRightSide) {
        if (this.isAnimating || this.isDragging) return;

        const pageData = this.getHoverPage(isRightSide);
        if (!pageData) return;

        this.isDragging = true;
        this.dragStartX = x;
        this.dragStartY = y;
        this.dragPage = pageData;
        this.dragDirection = this.visualToLogical(isRightSide ? 'next' : 'prev');
        this.dragProgress = 0;

        // Cancel ALL lift animations on all pages
        this.pages.forEach(p => {
            if (p.liftAnimationId) {
                cancelAnimationFrame(p.liftAnimationId);
                p.liftAnimationId = null;
            }
            if (p.currentLiftAmount > 0) {
                this.resetPageCurl(p);
            }
        });

        // Clear hover state
        if (this.hoveredPage) {
            this.resetPageCurl(this.hoveredPage);
            this.hoveredPage = null;
            this.hoveredCorner = null;
        }

        // Create flip shadow for drag on the page being revealed
        this._createFlipShadow(pageData, this.dragDirection);

        // In single page mode, hide all pages except the one being dragged
        if (this.forceSinglePage) {
            const dragPageIndex = this.pages.indexOf(pageData);
            this.pages.forEach((p, idx) => {
                if (idx === dragPageIndex) {
                    p.pivot.visible = true;
                    p.pivot.scale.set(1, 1, 1);
                    p.pivot.position.z = 0.02;
                } else {
                    p.pivot.visible = false;
                    p.pivot.scale.set(0, 0, 0);
                }
            });
        }

        // Store initial rotation
        this.dragPage.dragStartRotation = this.dragPage.pivot.rotation.y;
    }

    // Update drag flip based on mouse/touch position
    updateDragFlip(x, y) {
        if (!this.isDragging || !this.dragPage) return;

        // Get book bounds to calculate relative movement
        const fc = this.pages.filter(p => p.flipped).length;
        const singleView = this.forceSinglePage || fc === 0 || fc === this.pages.length;
        const bookBounds = singleView
            ? this.getVisiblePageScreenBounds()
            : this.getBookScreenBounds();
        if (!bookBounds) return;

        // In single page view, use full width; in double page mode, use half
        const pageWidth = singleView ? bookBounds.width : bookBounds.width / 2;

        // Calculate drag distance relative to page width
        let dragDelta;
        if (this.dragDirection === 'next') {
            // Dragging from right to left
            dragDelta = this.dragStartX - x;
        } else {
            // Dragging from left to right
            dragDelta = x - this.dragStartX;
        }

        // Calculate progress (0 to 1)
        this.dragProgress = Math.max(0, Math.min(1, dragDelta / pageWidth));

        // Calculate rotation based on progress
        const startRotation = this.dragDirection === 'next' ? 0 : -Math.PI;
        const endRotation = this.dragDirection === 'next' ? -Math.PI : 0;
        const currentRotation = startRotation + (endRotation - startRotation) * this.dragProgress;

        this.dragPage.pivot.rotation.y = currentRotation;

        // Reduced curl and lift for single page mode
        const curlMultiplier = this.forceSinglePage ? 0.4 : 0.8;
        const liftMultiplier = this.forceSinglePage ? 0.08 : 0.15;

        // Apply curl based on progress (peaks at middle of flip)
        const curlAmount = Math.sin(this.dragProgress * Math.PI);
        this.applyCurl(this.dragPage, curlAmount * curlMultiplier, this.dragProgress, this.dragDirection);

        // Lift page during drag
        this.dragPage.pivot.position.y = Math.sin(this.dragProgress * Math.PI) * liftMultiplier;

        // Z lift during drag
        const zLift = Math.sin(this.dragProgress * Math.PI) * 0.1;
        this.dragPage.pivot.position.z = 0.02 + zLift;

        // Update flip shadow during drag
        this._updateFlipShadow(this.dragPage, this.dragProgress, this.dragDirection);
    }

    // End drag flip - complete or revert based on progress
    endDragFlip() {
        if (!this.isDragging || !this.dragPage) return;

        this.isDragging = false;

        const threshold = 0.3; // 30% threshold to complete flip
        const shouldComplete = this.dragProgress >= threshold;

        if (shouldComplete) {
            // Complete the flip with animation
            this.completeDragFlip();
        } else {
            // Revert to original position
            this.revertDragFlip();
        }
    }

    // Complete the drag flip
    completeDragFlip() {
        const pageData = this.dragPage;
        const direction = this.dragDirection;
        const startProgress = this.dragProgress;
        const startTime = Date.now();
        const duration = CONFIG.flipDuration * (1 - startProgress); // Remaining duration

        this.isAnimating = true;
        this.playFlipSound();

        const startRotation = pageData.pivot.rotation.y;
        const targetRotation = this._flipTarget(direction);
        const startY = pageData.pivot.position.y;
        const startZ = pageData.pivot.position.z;

        // Reduced curl for single page mode
        const curlMultiplier = this.forceSinglePage ? 0.4 : 0.8;
        const liftMultiplier = this.forceSinglePage ? 0.08 : 0.15;

        const animate = () => {
            const elapsed = Date.now() - startTime;
            const animProgress = Math.min(elapsed / duration, 1);
            const eased = 1 - Math.pow(1 - animProgress, 3);

            // Overall progress from drag start to end
            const totalProgress = startProgress + (1 - startProgress) * eased;

            // Rotate
            pageData.pivot.rotation.y = startRotation + (targetRotation - startRotation) * eased;

            // Curl
            const curlAmount = Math.sin(totalProgress * Math.PI);
            this.applyCurl(pageData, curlAmount * curlMultiplier, totalProgress, direction);

            // Y lift
            pageData.pivot.position.y = Math.sin(totalProgress * Math.PI) * liftMultiplier;

            // Z position
            const zLift = Math.sin(totalProgress * Math.PI) * 0.1;
            pageData.pivot.position.z = 0.02 + zLift;

            // Update flip shadow
            this._updateFlipShadow(pageData, totalProgress, direction);

            if (animProgress < 1) {
                requestAnimationFrame(animate);
            } else {
                // Finish flip
                this.resetPageCurl(pageData);
                this._removeFlipShadow(pageData);
                pageData.pivot.position.y = 0;
                pageData.pivot.position.z = 0.02;
                pageData.flipped = direction === 'next';
                this.isAnimating = false;

                // Update z-ordering for ALL pages to ensure proper display
                this.updatePageZOrder();

                // Update state
                const flippedCount = this.pages.filter(p => p.flipped).length;
                this.currentSpread = Math.floor(flippedCount / 2) + 1;

                // Handle single page mode separately
                if (this.forceSinglePage) {
                    // Update currentSinglePage based on direction
                    this.currentSinglePage = direction === 'next'
                        ? this.currentSinglePage + 1
                        : this.currentSinglePage - 1;
                    this.updatePageVisibility();
                    this.updatePageIndicator();
                    this.updatePeelPositions();
                    // Update book position
                    const halfPageWidth = CONFIG.pageWidth / 2;
                    const isOddPage = this.currentSinglePage % 2 === 1;
                    const targetX = isOddPage ? -halfPageWidth : halfPageWidth;
                    this.targetBookX = targetX;
                    this.animateBookPosition(targetX);
                } else {
                    this.updatePageIndicator();
                    this.updateViewMode();
                }

                this._recheckPeelHover();
                this.dragPage = null;
                this.dragDirection = null;
                this.dragProgress = 0;
            }
        };

        animate();
    }

    // Revert drag flip back to original position
    revertDragFlip() {
        const pageData = this.dragPage;
        const direction = this.dragDirection;
        const startProgress = this.dragProgress;
        const startTime = Date.now();
        const duration = 300; // Quick revert

        this.isAnimating = true;
        const startRotation = pageData.pivot.rotation.y;
        const targetRotation = direction === 'next' ? 0 : -Math.PI;
        const startY = pageData.pivot.position.y;
        const startZ = pageData.pivot.position.z;

        // Reduced curl for single page mode
        const curlMultiplier = this.forceSinglePage ? 0.4 : 0.8;

        const animate = () => {
            const elapsed = Date.now() - startTime;
            const animProgress = Math.min(elapsed / duration, 1);
            const eased = 1 - Math.pow(1 - animProgress, 3);

            // Progress goes from current back to 0
            const totalProgress = startProgress * (1 - eased);

            // Rotate back
            pageData.pivot.rotation.y = startRotation + (targetRotation - startRotation) * eased;

            // Curl reduces
            const curlAmount = Math.sin(totalProgress * Math.PI);
            this.applyCurl(pageData, curlAmount * curlMultiplier, totalProgress, direction);

            // Y and Z ease back
            pageData.pivot.position.y = startY * (1 - eased);
            pageData.pivot.position.z = startZ + (0.02 - startZ) * eased;

            // Update flip shadow (fading out as we revert)
            this._updateFlipShadow(pageData, totalProgress, direction);

            if (animProgress < 1) {
                requestAnimationFrame(animate);
            } else {
                // Reset to flat
                this.resetPageCurl(pageData);
                this._removeFlipShadow(pageData);
                pageData.pivot.position.y = 0;
                pageData.pivot.position.z = 0.02;
                this.isAnimating = false;

                this._recheckPeelHover();
                this.dragPage = null;
                this.dragDirection = null;
                this.dragProgress = 0;
            }
        };

        animate();
    }

    // Zoom levels for stepping through
    getZoomLevels() {
        return [50, 75, 100, 125, 150, 200, 300, 400];
    }

    // ==========================================
    // Flip Effects (slide, fade, fold)
    // ==========================================


    // Show "Ctrl + scroll to zoom" hint when user scrolls without modifier
    _showScrollZoomHint() {
        if (this._scrollHintVisible) return;
        this._scrollHintVisible = true;

        const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
        const key = isMac ? '⌘' : 'Ctrl';
        const msg = t('zoom.scrollHint') || `Use ${key} + scroll to zoom`;

        const hint = document.createElement('div');
        hint.style.cssText = `
            position: absolute;
            top: 50%; left: 50%;
            transform: translate(-50%, -50%);
            background: rgba(0, 0, 0, 0.75);
            color: #fff;
            padding: 10px 20px;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 500;
            pointer-events: none;
            z-index: 1000;
            backdrop-filter: blur(4px);
            opacity: 0;
            transition: opacity 0.2s;
            white-space: nowrap;
        `;
        hint.textContent = msg;
        this.container.appendChild(hint);

        // Fade in
        requestAnimationFrame(() => { hint.style.opacity = '1'; });

        // Fade out after 1.5s
        setTimeout(() => {
            hint.style.opacity = '0';
            setTimeout(() => {
                hint.remove();
                this._scrollHintVisible = false;
            }, 200);
        }, 1500);
    }

    // Set zoom level
    setZoom(level, mode = null) {
        const prevZoom = this.zoomLevel;
        // When called with a numeric level and no explicit mode, treat as manual
        // so auto-fit recalcs on page change don't snap the user out of their zoom.
        this.zoomMode = mode || 'manual';

        if (mode === 'auto') {
            // Auto: fit to viewport while maintaining aspect ratio
            level = this.calculateAutoZoom();
        } else if (mode === 'page-fit') {
            // Page Fit: fit entire page height in view
            level = this.calculatePageFitZoom();
        } else if (mode === 'page-width') {
            // Page Width: fit page width in view
            level = this.calculatePageWidthZoom();
        }

        // Clamp zoom level
        this.zoomLevel = Math.max(25, Math.min(400, level));

        // Vertical viewer: apply zoom as CSS max-width scaling
        if (this.isVerticalViewer) {
            this.applyVerticalViewerZoom();
            this.updateZoomDisplay();
            return;
        }

        // Horizontal viewer: apply zoom as CSS max-height scaling
        if (this.isHorizontalViewer) {
            this.applyHorizontalViewerZoom();
            this.updateZoomDisplay();
            return;
        }

        // Reset pan when zooming out to 100% or below
        if (this.zoomLevel <= 100) {
            this.panX = 0;
            this.panY = 0;
        }

        // Update camera position (or CSS scale for 2D)
        if (this.is2D) {
            if (this._2d_bookEl) {
                const rot = this._2d_currentRotation || 0;
                const tx = this._2d_getTranslateX();
                this._2d_bookEl.style.transform = `translate(${tx}, -50%) scale(${this.zoomLevel / 100}) rotate(${rot}deg)`;
                this._2d_updateLayout();
            }
            this.updateHandToolForZoom();
            this.updateZoomDisplay();
            return;
        }
        this.updateCameraPosition();

        // Auto-toggle hand tool based on zoom level
        this.updateHandToolForZoom();

        // Update dropdown to show current zoom
        this.updateZoomDisplay();

        // Update minimap
        this.updateMinimap();

        // Track zoom change in analytics (only if changed)
        if (prevZoom !== this.zoomLevel) {
            analytics.trackZoom(this.zoomLevel, this.zoomMode);
            // Announce zoom to screen readers
            if (typeof a11y !== 'undefined' && a11y) {
                a11y.announce(`Zoom ${this.zoomLevel}%`);
            }
            if (this._tncfb) this._tncfb.emit('zoom:change', { level: this.zoomLevel });
        }
    }

    updateCameraPosition() {
        const baseDistance = 8;
        this.camera.position.z = baseDistance * (100 / this.zoomLevel);
        this.camera.position.x = this.panX;
        this.camera.position.y = 0.3 + this.panY;
        this.camera.lookAt(this.panX, this.panY, 0);
    }

    calculateAutoZoom() {
        if (this.is2D) return this._2d_calculateAutoZoom();

        // 3D: Calculate zoom to fit book nicely in viewport
        const flippedCount = this.pages ? this.pages.filter(p => p.flipped).length : 0;
        const atEdge = !this.pages || flippedCount === this.minFlipped || flippedCount === this.pages.length;
        const isSingleView = this.forceSinglePage || atEdge;
        const bookWidth = isSingleView ? CONFIG.pageWidth : CONFIG.pageWidth * 2;
        const bookHeight = CONFIG.pageHeight;
        const aspect = window.innerWidth / window.innerHeight;

        const fov = this.camera.fov * (Math.PI / 180);
        const baseDistance = CONFIG.cameraDistance || 8;

        const visibleHeight = 2 * Math.tan(fov / 2) * baseDistance;
        const visibleWidth = visibleHeight * aspect;

        const zoomForHeight = (visibleHeight / (bookHeight * 1.3)) * 100;
        const zoomForWidth = (visibleWidth / (bookWidth * 1.15)) * 100;

        return Math.min(zoomForHeight, zoomForWidth);
    }

    calculatePageFitZoom() {
        if (this.is2D) return this._2d_calculatePageFitZoom();

        // 3D: Fit full page height in view
        const bookHeight = CONFIG.pageHeight;
        const fov = this.camera.fov * (Math.PI / 180);
        const baseDistance = 8;

        const visibleHeight = 2 * Math.tan(fov / 2) * baseDistance;
        return (visibleHeight / (bookHeight * 1.4)) * 100;
    }

    calculatePageWidthZoom() {
        if (this.is2D) return this._2d_calculatePageWidthZoom();

        // 3D: Fit book width in view
        const isSingleView = this.forceSinglePage;
        const bookWidth = isSingleView ? CONFIG.pageWidth : CONFIG.pageWidth * 2;
        const aspect = window.innerWidth / window.innerHeight;
        const fov = this.camera.fov * (Math.PI / 180);
        const baseDistance = 8;

        const visibleHeight = 2 * Math.tan(fov / 2) * baseDistance;
        const visibleWidth = visibleHeight * aspect;

        return (visibleWidth / (bookWidth * 1.15)) * 100;
    }

    // 2D zoom calculations: based on container vs book dimensions
    _2d_calculateAutoZoom() {
        // In 2D, _2d_onResize already fits the book at scale(1). Auto = 100%.
        return 100;
    }

    _2d_calculatePageFitZoom() {
        // Scale so the full page height fills the container
        if (!this._2d_bookEl) return 100;
        const containerH = this.container.clientHeight;
        const bookH = this._2d_bookEl.offsetHeight;
        if (bookH === 0) return 100;
        return (containerH / bookH) * 100 * 0.95; // 95% to leave some padding
    }

    _2d_calculatePageWidthZoom() {
        // Scale so the book width fills the container
        if (!this._2d_bookEl) return 100;
        const containerW = this.container.clientWidth;
        const bookW = this._2d_bookEl.offsetWidth;
        if (bookW === 0) return 100;
        return (containerW / bookW) * 100 * 0.95;
    }

    updateZoomDisplay() {
        const select = document.getElementById('zoomSelect');

        // Check if current zoom matches a preset
        const levels = this.getZoomLevels();
        const roundedZoom = Math.round(this.zoomLevel);

        if (this.zoomMode === 'auto') {
            select.value = 'auto';
        } else if (this.zoomMode === 'page-fit') {
            select.value = 'page-fit';
        } else if (this.zoomMode === 'page-width') {
            select.value = 'page-width';
        } else if (levels.includes(roundedZoom)) {
            select.value = roundedZoom.toString();
        } else {
            // Custom zoom - update select to show nearest or add custom option
            const nearest = levels.reduce((prev, curr) =>
                Math.abs(curr - roundedZoom) < Math.abs(prev - roundedZoom) ? curr : prev
            );
            select.value = nearest.toString();
        }
    }

    zoomIn() {
        const levels = this.getZoomLevels();
        const currentIndex = levels.findIndex(l => l >= this.zoomLevel);
        if (currentIndex < levels.length - 1) {
            this.setZoom(levels[currentIndex === -1 ? levels.length - 1 : currentIndex + 1]);
        } else {
            this.setZoom(levels[levels.length - 1]);
        }
    }

    zoomOut() {
        const levels = this.getZoomLevels();
        const currentIndex = levels.findIndex(l => l >= this.zoomLevel);
        if (currentIndex > 0) {
            this.setZoom(levels[currentIndex - 1]);
        } else if (currentIndex === -1) {
            this.setZoom(levels[levels.length - 1]);
        } else {
            this.setZoom(levels[0]);
        }
    }

    // Minimap functionality
    updateMinimap() {
        const minimap = document.getElementById('minimap');
        const minimapCanvas = document.getElementById('minimapCanvas');
        const minimapViewport = document.getElementById('minimapViewport');

        // Show minimap only when zoomed in
        if (this.zoomLevel > 110) {
            minimap.classList.add('active');
            this.renderMinimapThumbnail();

            // Calculate viewport indicator position and size
            const visibleRatio = 100 / this.zoomLevel;
            const canvasRect = minimapCanvas.getBoundingClientRect();

            // Viewport size
            const vpWidth = canvasRect.width * visibleRatio;
            const vpHeight = canvasRect.height * visibleRatio;

            // Viewport position based on pan
            const maxPan = (this.zoomLevel - 100) / 50;
            const panRatioX = maxPan > 0 ? this.panX / maxPan : 0;
            const panRatioY = maxPan > 0 ? this.panY / (maxPan * 0.7) : 0;

            // Center position plus pan offset. panRatioX < 0 means the camera
            // is shifted left (user sees the left side of the page), so the
            // minimap indicator must move left too — hence + not -.
            const vpX = (canvasRect.width - vpWidth) / 2 + panRatioX * (canvasRect.width - vpWidth) / 2;
            const vpY = (canvasRect.height - vpHeight) / 2 - panRatioY * (canvasRect.height - vpHeight) / 2;

            minimapViewport.style.width = `${vpWidth}px`;
            minimapViewport.style.height = `${vpHeight}px`;
            minimapViewport.style.left = `${Math.max(0, Math.min(canvasRect.width - vpWidth, vpX))}px`;
            minimapViewport.style.top = `${Math.max(0, Math.min(canvasRect.height - vpHeight, vpY))}px`;
        } else {
            minimap.classList.remove('active');
        }
    }

    renderMinimapThumbnail() {
        const canvas = document.getElementById('minimapCanvas');
        const ctx = canvas.getContext('2d');

        // Determine the current visible page from the live page state, not from
        // currentSpread (which doesn't track single-page navigation and can be
        // stale if the minimap is repainted before currentSpread is updated).
        let leftPageNum, rightPageNum;
        if (this.forceSinglePage) {
            leftPageNum = this.currentSinglePage || 1;
            rightPageNum = null;
        } else {
            const flippedCount = this.pages.filter(p => p.flipped).length;
            if (this.isEvenPageMode) {
                // Spreads: (1,2), (3,4), ... left = flippedCount*2 + 1
                const left = flippedCount * 2 + 1;
                leftPageNum = left;
                rightPageNum = left + 1 <= this.numPages ? left + 1 : null;
            } else if (flippedCount === 0) {
                leftPageNum = 1;
                rightPageNum = null;
            } else if (flippedCount === this.pages.length) {
                leftPageNum = this.numPages;
                rightPageNum = null;
            } else {
                // Spreads: (2,3), (4,5), ... left = flippedCount*2
                const left = flippedCount * 2;
                leftPageNum = left;
                rightPageNum = left + 1 <= this.numPages ? left + 1 : null;
            }
        }

        const aspectRatio = CONFIG.pageHeight / ((rightPageNum ? CONFIG.pageWidth * 2 : CONFIG.pageWidth));
        canvas.width = 160;
        canvas.height = Math.round(160 * aspectRatio);

        ctx.fillStyle = '#2a2a35';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Token guards against stale async image loads from prior page changes.
        const token = (this._minimapToken = (this._minimapToken || 0) + 1);
        const drawImageAt = (idx, dx, dw) => {
            const dataUrl = this.getPageDataURL(idx);
            if (!dataUrl) return;
            const img = new Image();
            img.onload = () => {
                if (token !== this._minimapToken) return; // superseded
                ctx.drawImage(img, dx, 0, dw, canvas.height);
            };
            img.src = dataUrl;
        };

        if (!rightPageNum) {
            drawImageAt(leftPageNum - 1, 0, canvas.width);
        } else {
            const halfWidth = canvas.width / 2;
            drawImageAt(leftPageNum - 1, 0, halfWidth);
            drawImageAt(rightPageNum - 1, halfWidth, halfWidth);
        }
    }

    handleMinimapClick(e) {
        const canvas = document.getElementById('minimapCanvas');
        const rect = canvas.getBoundingClientRect();

        // Get click position relative to canvas center
        const clickX = e.clientX - rect.left;
        const clickY = e.clientY - rect.top;
        const centerX = rect.width / 2;
        const centerY = rect.height / 2;

        // Calculate pan values from click position
        const maxPan = (this.zoomLevel - 100) / 50;
        const visibleRatio = 100 / this.zoomLevel;

        // Pan to center the clicked point in view. Click left of center → user
        // wants to see the left side of the page → panX must be negative.
        const panRatioX = (clickX - centerX) / (rect.width * (1 - visibleRatio) / 2);
        const panRatioY = (centerY - clickY) / (rect.height * (1 - visibleRatio) / 2);

        this.panX = Math.max(-maxPan, Math.min(maxPan, panRatioX * maxPan));
        this.panY = Math.max(-maxPan * 0.7, Math.min(maxPan * 0.7, panRatioY * maxPan * 0.7));

        this.updateCameraPosition();
        this.updateMinimap();
    }

    // Auto play functionality
    toggleAutoPlay() {
        this.autoPlayEnabled = !this.autoPlayEnabled;
        analytics.trackAutoPlay(this.autoPlayEnabled);

        if (this.autoPlayEnabled) {
            this.startAutoPlay();
        } else {
            this.stopAutoPlay();
        }
    }

    startAutoPlay() {
        if (this.autoPlayInterval) {
            clearInterval(this.autoPlayInterval);
        }

        if (this._tncfb) this._tncfb.emit('autoplay:start', {});

        this.autoPlayInterval = setInterval(() => {
            if (this.forceSinglePage) {
                // Single page mode
                if (this.currentSinglePage >= this.numPages) {
                    // Loop back to start
                    this.goToSinglePage(1);
                } else {
                    this.flipPage('next');
                }
            } else {
                // Double page mode
                const flippedCount = this.pages.filter(p => p.flipped).length;
                if (flippedCount >= this.pages.length) {
                    // Loop back to start
                    this.goToFlippedState(this.minFlipped);
                } else {
                    this.flipPage('next');
                }
            }
        }, this.autoPlayDelay);
    }

    stopAutoPlay() {
        if (this.autoPlayInterval) {
            clearInterval(this.autoPlayInterval);
            this.autoPlayInterval = null;
            if (this._tncfb) this._tncfb.emit('autoplay:stop', {});
        }
    }

    // Single page mode
    toggleSinglePageMode() {
        this.forceSinglePage = !this.forceSinglePage;

        // If in vertical viewer, re-render with new layout
        if (this.isVerticalViewer) {
            const currentPage = this._verticalCurrentPage || 1;
            this.populateVerticalViewer();
            // Scroll back to the page we were on
            requestAnimationFrame(() => {
                const targetEl = document.getElementById(`vertical-page-${currentPage}`);
                if (targetEl) {
                    targetEl.scrollIntoView({ behavior: 'instant', block: 'start' });
                }
            });
            { const _spBtn = document.getElementById('singlePageBtn'); if (_spBtn) _spBtn.classList.toggle('active', this.forceSinglePage); }
            this.updatePageIndicator();
            return;
        }

        // If in horizontal viewer, re-render with new layout
        if (this.isHorizontalViewer) {
            const currentPage = this._horizontalCurrentPage || 1;
            this.populateHorizontalViewer();
            // Scroll back to the page we were on
            requestAnimationFrame(() => {
                const targetEl = document.getElementById(`horizontal-page-${currentPage}`);
                if (targetEl) {
                    targetEl.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'center' });
                }
            });
            { const _spBtn = document.getElementById('singlePageBtn'); if (_spBtn) _spBtn.classList.toggle('active', this.forceSinglePage); }
            this.updatePageIndicator();
            return;
        }

        // --- 2D mode ---
        if (this.is2D) {
            const bookEl = this._2d_bookEl;
            const goingSingle = this.forceSinglePage;

            // Pre-state work (set viewMode/currentSinglePage so swap is correct)
            if (goingSingle) {
                const flippedCount = this.pages.filter(p => p.flipped).length;
                this.currentSinglePage = flippedCount === this.minFlipped ? 1 : (this.isEvenPageMode ? flippedCount * 2 - 1 : flippedCount * 2);
                this.viewMode = 'single';
            } else {
                // Restore all sheets visibility before re-laying out
                this.pages.forEach(p => {
                    if (p.sheetEl) p.sheetEl.style.display = '';
                    if (p.frontEl) p.frontEl.style.display = '';
                    if (p.backEl) p.backEl.style.display = '';
                });
            }

            this._2d_animateModeSwitch(goingSingle, () => {
                if (goingSingle) {
                    bookEl?.classList.add('tncfb-single-page');
                    this._2d_onResize();
                    this._2d_updateSinglePageVisibility();
                    this._2d_updateLayout();
                } else {
                    bookEl?.classList.remove('tncfb-single-page');
                    this._2d_onResize();
                    this._2d_updateZOrder();
                    this._2d_updateLayout();
                    this.updateViewMode();
                }
                this.updatePageIndicator();
            });

            this.showNotification(t(goingSingle ? 'notify.singlePage' : 'notify.doublePage'));
            { const _spBtn = document.getElementById('singlePageBtn'); if (_spBtn) _spBtn.classList.toggle('active', this.forceSinglePage); }
            return;
        }

        if (this.forceSinglePage) {
            // Calculate current single page based on flipped state
            const flippedCount = this.pages.filter(p => p.flipped).length;
            if (flippedCount === this.minFlipped) {
                this.currentSinglePage = 1;
            } else {
                // Show the left page of current spread
                this.currentSinglePage = this.isEvenPageMode ? flippedCount * 2 - 1 : flippedCount * 2;
            }

            // Force single page view
            this.viewMode = 'single';

            // Determine which sheet to show
            const isOddPage = this.currentSinglePage % 2 === 1;
            let visibleSheetIndex = -1;
            if (isOddPage) {
                visibleSheetIndex = this.pages.findIndex(p => !p.flipped);
            } else {
                for (let i = this.pages.length - 1; i >= 0; i--) {
                    if (this.pages[i].flipped) {
                        visibleSheetIndex = i;
                        break;
                    }
                }
            }

            // Safety fallback
            if (visibleSheetIndex === -1) {
                visibleSheetIndex = isOddPage ? 0 : this.pages.length - 1;
                if (visibleSheetIndex < 0) visibleSheetIndex = 0;
            }

            // Save real-sheet z values and hide them — the ghost flipper
            // is the visible page in single-page mode. visibleSheetIndex
            // is still tracked above for completeness / fallback, but
            // _singlePageFlipperShowCurrent() takes over rendering.
            this.pages.forEach((p) => {
                if (p.savedZ === undefined) {
                    p.savedZ = p.pivot.position.z;
                }
                p.pivot.visible = false;
                p.pivot.scale.set(0, 0, 0);
                p.pivot.position.z = -100;
            });

            this._singlePageFlipperShowCurrent();
            this._singlePageFlipperReady = true;

            // Update shadow, crease, styles immediately
            this.updateDropShadowLayout();
            if (this.spineShadow) {
                this.spineShadow.visible = false;
            }
            if (this.creaseLine) {
                this.creaseLine.visible = false;
            }
            this.updateHardcoverVisibility();
            this.updateSpiralPosition();
            this.updatePageIndicator();
            this.updatePeelPositions();
            this.showNotification(t('notify.singlePage'));
        } else {
            // Return to automatic mode — hide ghost flipper, restore real
            // sheets (which already track the correct .flipped state).
            this._singlePageFlipperHide();
            this._singlePageFlipperReady = false;
            this.pages.forEach(p => {
                p.pivot.visible = true;
                p.pivot.scale.set(1, 1, 1);
                if (p.savedZ !== undefined) {
                    p.pivot.position.z = p.savedZ;
                    delete p.savedZ;
                }
                p.pivot.rotation.y = p.flipped ? -Math.PI : 0;
            });
            // Show spine shadow and crease line again
            if (this.spineShadow) {
                this.spineShadow.visible = true;
            }
            if (this.creaseLine) {
                this.creaseLine.visible = true;
            }
            this.updateHardcoverVisibility();
            this.updateSpiralPosition();
            this.updatePageZOrder();
            this.updateViewMode();
            this.updatePageIndicator();
            this.showNotification(t('notify.doublePage'));
        }

        // Recalculate auto-zoom since effective book width changed
        if (!this.is2D && this.camera && (this.zoomMode === 'auto' || this.zoomMode === null)) {
            const fitZoom = this.calculateAutoZoom();
            if (fitZoom < 100) {
                this.zoomLevel = fitZoom;
                this.zoomMode = 'auto';
            } else if (this.zoomMode === 'auto') {
                this.zoomLevel = 100;
            }
            this.updateCameraPosition();
            this.updateZoomDisplay();
        }
    }

    // Handle flipping in single page mode
    flipSinglePage(direction) {
        if (this.isAnimating) return;

        const fromPage = this.currentSinglePage;
        const targetPage = direction === 'next'
            ? this.currentSinglePage + 1
            : this.currentSinglePage - 1;

        // Bounds check
        if (targetPage < 1 || targetPage > this.numPages) return;

        // Calculate required flipped sheets for target page
        const targetFlipped = Math.floor((targetPage + (this.isEvenPageMode ? 1 : 0)) / 2);

        const currentFlipped = this.pages.filter(p => p.flipped).length;

        this.currentSinglePage = targetPage;

        // Priority-render the target page if the loader hasn't reached it yet.
        this._prefetchJumpTargets(targetFlipped);

        // 2D single page flip — CSS page-turn animation on the visible sheet
        if (this.is2D) {
            this.isAnimating = true;
            this.playFlipSound();

            const duration = CONFIG.flipDuration;
            const isForward = direction === 'next';
            const turnClass = isForward ? 'tncfb-turn-next' : 'tncfb-turn-prev';

            // Find the currently visible sheet
            const visibleSheet = this._2d_bookEl?.querySelector('.tncfb-sheet[style*="display: block"], .tncfb-sheet:not([style*="display: none"])');

            if (visibleSheet) {
                visibleSheet.style.animationDuration = duration + 'ms';
                visibleSheet.classList.add('tncfb-sheet-turning', turnClass);
            }

            // At the midpoint, swap to the new page
            setTimeout(() => {
                this.pages.forEach((p, i) => {
                    const shouldBeFlipped = i < targetFlipped;
                    p.flipped = shouldBeFlipped;
                    if (p.sheetEl) {
                        if (shouldBeFlipped) p.sheetEl.classList.add('flipped');
                        else p.sheetEl.classList.remove('flipped');
                    }
                });
                this._2d_updateSinglePageVisibility();
                this._2d_updateZOrder();
                this._2d_updateLayout();
                this.updatePageIndicator();

                // Apply animation to the new visible sheet for the second half
                const newVisibleSheet = this._2d_bookEl?.querySelector('.tncfb-sheet[style*="display: block"], .tncfb-sheet:not([style*="display: none"])');
                if (newVisibleSheet && newVisibleSheet !== visibleSheet) {
                    newVisibleSheet.style.animationDuration = duration + 'ms';
                    newVisibleSheet.classList.add('tncfb-sheet-turning', turnClass);
                }
            }, duration * 0.4);

            // Clean up after animation
            setTimeout(() => {
                this._2d_bookEl?.querySelectorAll('.tncfb-sheet-turning').forEach(el => {
                    el.classList.remove('tncfb-sheet-turning', 'tncfb-turn-next', 'tncfb-turn-prev');
                    el.style.animationDuration = '';
                });
                this.isAnimating = false;
            }, duration);

            return;
        }

        // Update hardcover/spiral for new page
        this.updateHardcoverVisibility();
        this.updateSpiralPosition();

        // Ghost-flipper path — every single-page turn animates the same
        // dedicated mesh, producing one consistent flip aesthetic. See
        // _singlePageGhostFlip above for rationale. On the very first
        // single-page turn (right after toggling into the mode) we hand
        // off from the real sheets to the flipper by parking it at the
        // outgoing page; subsequent turns chain directly through
        // _singlePageGhostFlip's invisible reset.
        if (!this._singlePageFlipperReady) {
            this.currentSinglePage = fromPage;
            this._singlePageFlipperShowCurrent();
            this._singlePageFlipperReady = true;
            this.currentSinglePage = targetPage;
        }
        this._singlePageGhostFlip(targetPage, direction);
    }

    // Animate a page turn for single-page mode when the target page lives
    // on a different sheet than the current one but the *flipped count*
    // doesn't change (e.g., going from the back face of one sheet to the
    // front face of the next).
    //
    // Produces a single continuous 180° arc — visually identical to
    // animateSinglePageFlip — by computing one eased rotation across the
    // whole duration. The current sheet handles the first 90° (face → edge-
    // on), then we hand the remaining 90° (edge-on → face) off to the
    // target sheet. Both sheets share the same instantaneous rotation
    // angle at the swap moment so the eye reads it as one arc.
    //
    // Earlier implementation ran two independent easing curves (one per
    // half) which deceled at the swap and re-accelerated, producing a
    // visibly different flip from the regular per-sheet flip.
    animateSinglePageSlide(targetPage, direction, silent = false) {
        this.isAnimating = true;
        if (this.dropShadow) this.dropShadow.visible = false;
        if (!silent) this.playFlipSound();

        const isTargetOdd = targetPage % 2 === 1;
        const halfPageWidth = CONFIG.pageWidth / 2;
        const targetBookX = isTargetOdd ? -halfPageWidth : halfPageWidth;
        const startBookX = this.book.position.x;

        // Find current + target sheet indices
        let currentSheetIndex = -1;
        let targetSheetIndex = -1;
        if (isTargetOdd) {
            targetSheetIndex = this.pages.findIndex(p => !p.flipped);
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) { currentSheetIndex = i; break; }
            }
        } else {
            currentSheetIndex = this.pages.findIndex(p => !p.flipped);
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) { targetSheetIndex = i; break; }
            }
        }
        if (targetSheetIndex === -1) targetSheetIndex = currentSheetIndex >= 0 ? currentSheetIndex : 0;
        if (currentSheetIndex === -1) currentSheetIndex = targetSheetIndex;

        const currentSheet = this.pages[currentSheetIndex];
        const targetSheet = this.pages[targetSheetIndex];

        const currentIsFlipped = currentSheet.flipped;
        const targetIsFlipped = targetSheet.flipped;

        // 180° arc, split between two sheets. Direction sign matches the
        // regular per-sheet flip (`next` → negative Y rotation).
        const dir = direction === 'next' ? -1 : 1;
        const currentStartRotation = currentIsFlipped ? -Math.PI : 0;
        const targetEndRotation = targetIsFlipped ? -Math.PI : 0;
        const halfArc = Math.PI * 0.5 * dir;
        // targetStartRotation = where the target sheet "picks up" from the
        // current sheet at the edge-on moment. Choosing this so the rotation
        // angle is continuous across the swap.
        const targetStartRotation = targetEndRotation - halfArc;

        // Initial state: only current sheet visible
        this.pages.forEach((p, idx) => {
            if (idx === currentSheetIndex) {
                p.pivot.visible = true;
                p.pivot.scale.set(1, 1, 1);
                p.pivot.position.z = 0.02;
            } else {
                p.pivot.visible = false;
                p.pivot.scale.set(0, 0, 0);
            }
        });

        const startTime = Date.now();
        const duration = CONFIG.flipDuration;
        let switched = false;

        const animate = () => {
            const elapsed = Date.now() - startTime;
            const progress = Math.min(elapsed / duration, 1);
            // Single cubic-out easing across the WHOLE animation — this is
            // what makes the visible rotation continuous instead of two
            // half-flips with their own easings.
            const eased = 1 - Math.pow(1 - progress, 3);

            this.book.position.x = startBookX + (targetBookX - startBookX) * eased;

            // Continuous rotation along the 180° arc.
            const arc = eased * Math.PI * dir; // 0 → ±π over the animation
            const inFirstHalf = Math.abs(arc) < Math.abs(halfArc);

            // Lift + curl profile mirrors animateSinglePageFlip — peaks at
            // mid-flip (edge-on) and returns to zero at both ends.
            const sineT = Math.sin(progress * Math.PI);
            const lift = sineT * 0.08;
            const curlAmount = sineT;

            if (inFirstHalf) {
                currentSheet.pivot.rotation.y = currentStartRotation + arc;
                currentSheet.pivot.position.y = lift;
                currentSheet.pivot.position.z = 0.02 + lift;
                this.applyCurl(currentSheet, curlAmount * 0.4, progress, direction);
            } else {
                if (!switched) {
                    switched = true;
                    // Park the current sheet back at its true logical
                    // rotation so its state stays clean.
                    currentSheet.pivot.visible = false;
                    currentSheet.pivot.scale.set(0, 0, 0);
                    this.resetPageCurl(currentSheet);
                    currentSheet.pivot.rotation.y = currentStartRotation;
                    currentSheet.pivot.position.y = 0;
                    currentSheet.pivot.position.z = 0.02;
                    // Bring target onstage at the same edge-on angle the
                    // current sheet just left, so the arc reads as one.
                    targetSheet.pivot.visible = true;
                    targetSheet.pivot.scale.set(1, 1, 1);
                    targetSheet.pivot.position.z = 0.02;
                }
                // Remaining arc still to traverse on the target sheet.
                const remainingArc = arc - halfArc;
                targetSheet.pivot.rotation.y = targetStartRotation + remainingArc;
                targetSheet.pivot.position.y = lift;
                targetSheet.pivot.position.z = 0.02 + lift;
                this.applyCurl(targetSheet, curlAmount * 0.4, progress, direction);
            }

            if (progress < 1) {
                requestAnimationFrame(animate);
            } else {
                this.resetPageCurl(targetSheet);
                targetSheet.pivot.rotation.y = targetEndRotation;
                targetSheet.pivot.position.y = 0;
                targetSheet.pivot.position.z = 0.02;
                this.book.position.x = targetBookX;
                this.targetBookX = targetBookX;
                this.isAnimating = false;

                this.pages.forEach((p, idx) => {
                    if (idx === targetSheetIndex) {
                        p.pivot.visible = true;
                        p.pivot.scale.set(1, 1, 1);
                    } else {
                        p.pivot.visible = false;
                        p.pivot.scale.set(0, 0, 0);
                    }
                });

                this.updatePageIndicator();
                this.updatePeelPositions();
                this.updateDropShadowLayout();
                if (this.dropShadow) this.dropShadow.visible = true;
                if (this.spineShadow) this.spineShadow.visible = false;
                if (this.creaseLine) this.creaseLine.visible = false;
            }
        };

        animate();
    }

    // Animate a single page flip (may involve flipping a sheet)
    animateSinglePageFlip(targetFlipped, direction, targetPage, silent = false, rapid = false) {
        const currentFlipped = this.pages.filter(p => p.flipped).length;

        if (currentFlipped === targetFlipped) {
            this.isAnimating = false;
            this.updatePageZOrder();
            this.updateViewMode();
            this.updatePageIndicator();
            if (this.dropShadow) this.dropShadow.visible = true;
            return;
        }

        this.isAnimating = true;

        // Hide shadow during flip to avoid ghost page effect
        if (this.dropShadow) this.dropShadow.visible = false;

        // Cancel ALL lift animations
        this.pages.forEach(p => {
            if (p.liftAnimationId) {
                cancelAnimationFrame(p.liftAnimationId);
                p.liftAnimationId = null;
            }
            if (p.currentLiftAmount > 0) {
                this.resetPageCurl(p);
            }
        });

        if (this.hoveredPage) {
            this.resetPageCurl(this.hoveredPage);
            this.hoveredPage = null;
        }
        this.hoveredCorner = null;

        let pageIndex;
        if (direction === 'next') {
            pageIndex = this.pages.findIndex(p => !p.flipped);
        } else {
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) {
                    pageIndex = i;
                    break;
                }
            }
        }

        if (pageIndex === undefined || pageIndex === -1) {
            this.isAnimating = false;
            if (this.dropShadow) this.dropShadow.visible = true;
            return;
        }

        // For single page mode: only show the flipping page, hide all others
        const flippingPage = this.pages[pageIndex];
        this.pages.forEach((p, idx) => {
            if (idx === pageIndex) {
                p.pivot.visible = true;
                p.pivot.scale.set(1, 1, 1);
                if (p.savedZ !== undefined) {
                    p.pivot.position.z = p.savedZ;
                    delete p.savedZ;
                }
            } else {
                p.pivot.visible = false;
                p.pivot.scale.set(0, 0, 0);
            }
        });

        if (!silent) this.playFlipSound();

        const pageData = flippingPage;
        if (pageData.currentLiftAmount > 0) {
            this.resetPageCurl(pageData);
        }

        // Calculate book position to keep page centered during flip
        const halfPageWidth = CONFIG.pageWidth / 2;
        const isTargetOdd = targetPage % 2 === 1;
        const finalBookX = isTargetOdd ? -halfPageWidth : halfPageWidth;
        const startBookX = rapid ? 0 : this.book.position.x;
        const targetBookX = rapid ? 0 : finalBookX;
        // In rapid mode, keep book centered during flips — position is set on completion
        if (rapid) this.book.position.x = 0;

        const startZ = pageData.pivot.position.z;
        const targetZ = 0.02;
        const targetRotation = this._flipTarget(direction);
        const startRotation = pageData.pivot.rotation.y;
        const startTime = Date.now();

        const animateFlip = () => {
            const elapsed = Date.now() - startTime;
            const flipDuration = rapid ? CONFIG.fastFlipDuration : CONFIG.flipDuration;
            const progress = Math.min(elapsed / flipDuration, 1);
            const eased = 1 - Math.pow(1 - progress, 3);

            pageData.pivot.rotation.y = startRotation + (targetRotation - startRotation) * eased;

            const zLift = Math.sin(progress * Math.PI) * (rapid ? 0.03 : 0.08);
            pageData.pivot.position.z = startZ + (targetZ - startZ) * eased + zLift;

            // Reduced curl and lift for single page mode
            const curlAmount = Math.sin(progress * Math.PI);
            this.applyCurl(pageData, curlAmount * (rapid ? 0.2 : 0.4), progress, direction);

            pageData.pivot.position.y = Math.sin(progress * Math.PI) * (rapid ? 0.03 : 0.08);

            // Animate book position to follow the page
            this.book.position.x = startBookX + (targetBookX - startBookX) * eased;

            if (progress < 1) {
                requestAnimationFrame(animateFlip);
            } else {
                this.resetPageCurl(pageData);
                pageData.pivot.position.y = 0;
                pageData.pivot.position.z = targetZ;
                pageData.flipped = direction === 'next';

                // Check if we need to flip more sheets
                const newFlippedCount = this.pages.filter(p => p.flipped).length;
                if (newFlippedCount !== targetFlipped) {
                    // More sheets to flip - chain the animation
                    setTimeout(() => {
                        this.animateSinglePageFlip(targetFlipped, direction, targetPage, silent, rapid);
                    }, rapid ? 10 : 50);
                } else {
                    // Done flipping - finalize
                    this.isAnimating = false;
                    this.book.position.x = finalBookX;
                    this.targetBookX = finalBookX;
                    this.updatePageZOrder();
                    this.updatePageIndicator();
                    this.updatePageVisibility();
                    this.updatePeelPositions();
                    this.updateDropShadowLayout();
                    if (this.dropShadow) this.dropShadow.visible = true;
                }
            }
        };

        animateFlip();
    }

    // ==========================================
    // Reading Mode
    // ==========================================

    toggleReadingMode() {
        this.isReadingMode = !this.isReadingMode;

        if (this.isReadingMode) {
            this.enterReadingMode();
        } else {
            this.exitReadingMode();
        }
    }

    enterReadingMode() {
        // Scroll-native reading mode for vertical/horizontal viewers
        if (this.isVerticalViewer || this.isHorizontalViewer) {
            return this.enterScrollReadingMode();
        }

        // Remember which viewer mode was active so we can restore it on exit
        let previousViewerMode = 'flipbook';
        if (this.isVerticalViewer) {
            previousViewerMode = 'vertical';
            this.exitVerticalViewer();
        }
        if (this.isHorizontalViewer) {
            previousViewerMode = 'horizontal';
            this.exitHorizontalViewer();
        }

        // Save current state
        this.readingModePreState = {
            isTilted: this.isTilted,
            forceSinglePage: this.forceSinglePage,
            zoomLevel: this.zoomLevel,
            zoomMode: this.zoomMode,
            rotationAngle: this.rotationAngle,
            panX: this.panX,
            panY: this.panY,
            previousViewerMode,
        };

        // Switch to single page mode if not already
        if (!this.forceSinglePage) {
            this.toggleSinglePageMode();
        }

        // Flatten tilt
        if (this.isTilted) {
            this.isTilted = false;
            this.animateBookTilt(0);
            document.getElementById('tiltBtn').classList.remove('active');
        }

        // Reset rotation
        if (this.rotationAngle !== 0) {
            this.rotationAngle = 0;
            this.animateBookRotation(0);
        }

        // Zoom to fill viewport height with minimal padding
        if (this.camera) {
            const rmFov = this.camera.fov * (Math.PI / 180);
            const rmBaseDistance = 8;
            const rmVisibleHeight = 2 * Math.tan(rmFov / 2) * rmBaseDistance;
            const rmZoom = (rmVisibleHeight / (CONFIG.pageHeight * 1.08)) * 100;
            this.setZoom(rmZoom);
        } else {
            // 2D mode: zoom to fit single page in viewport
            this.setZoom(130);
        }

        // Apply reading mode class
        document.body.classList.add('reading-mode');
        document.getElementById('readingModeBtn').classList.add('active');

        // Update Three.js background to match softer color
        const parseCssHex = (cssColor) => {
            const match = cssColor.match(/#([0-9a-fA-F]{6})/);
            return match ? parseInt(match[1], 16) : 0x141420;
        };
        if (this.renderer) {
            if (this.isDarkTheme) {
                if (!this._isTransparentBg) this.renderer.setClearColor(parseCssHex(this.sceneBgDark), 1);
            } else {
                if (!this._isTransparentBg) this.renderer.setClearColor(parseCssHex(this.sceneBgLight), 1);
            }
        }

        // Update reading indicator
        this.updateReadingIndicator();

        // Setup auto-hide toolbar
        this.setupReadingModeToolbarAutoHide();
    }

    exitReadingMode() {
        // Scroll-native reading mode exit
        if (document.body.classList.contains('scroll-reading-mode')) {
            return this.exitScrollReadingMode();
        }

        this.isReadingMode = false;

        // Remove reading mode class
        document.body.classList.remove('reading-mode');
        document.getElementById('readingModeBtn').classList.remove('active');

        // Restore Three.js background
        if (this.renderer) {
            const parseCssHex = (cssColor) => {
                const match = cssColor.match(/#([0-9a-fA-F]{6})/);
                return match ? parseInt(match[1], 16) : 0x0a0a12;
            };
            if (this.isDarkTheme) {
                if (!this._isTransparentBg) this.renderer.setClearColor(parseCssHex(this.sceneBgDark), 1);
            } else {
                if (!this._isTransparentBg) this.renderer.setClearColor(parseCssHex(this.sceneBgLight), 1);
            }
        }

        // Tear down auto-hide
        this.teardownReadingModeToolbarAutoHide();

        // Restore previous state
        const pre = this.readingModePreState;
        if (pre) {
            // Restore single page mode
            if (!pre.forceSinglePage && this.forceSinglePage) {
                this.toggleSinglePageMode();
            }

            // Restore tilt
            if (pre.isTilted && !this.isTilted) {
                this.isTilted = true;
                this.animateBookTilt(-0.15);
                document.getElementById('tiltBtn').classList.add('active');
            }

            // Restore rotation
            if (pre.rotationAngle !== this.rotationAngle) {
                this.rotationAngle = pre.rotationAngle;
                this.animateBookRotation(-this.rotationAngle);
            }

            // Restore zoom
            if (pre.zoomMode) {
                this.setZoom(pre.zoomLevel, pre.zoomMode);
            } else {
                this.setZoom(pre.zoomLevel);
            }

            // Restore pan
            this.panX = pre.panX;
            this.panY = pre.panY;
            if (!this.is2D) this.updateCameraPosition();

            // Restore previous viewer mode (vertical/horizontal) if applicable
            if (pre.previousViewerMode === 'vertical') {
                this.enterVerticalViewer();
            } else if (pre.previousViewerMode === 'horizontal') {
                this.enterHorizontalViewer();
            }

            this.readingModePreState = null;
        }
    }

    enterScrollReadingMode() {
        this.isReadingMode = true;
        const currentPage = this.getCurrentPageNumber();

        // Save pre-state for restore
        this.scrollReadingPreState = {
            forceSinglePage: this.forceSinglePage,
        };

        // Force single page and repopulate if currently in spread mode
        if (!this.forceSinglePage) {
            this.forceSinglePage = true;
            if (this.isVerticalViewer) {
                const vc = document.getElementById('verticalViewerContainer');
                if (this.verticalViewerScrollHandler) vc.removeEventListener('scroll', this.verticalViewerScrollHandler);
                this.populateVerticalViewer();
                this.setupVerticalScrollTracking();
            } else {
                const hc = document.getElementById('horizontalViewerContainer');
                if (this.horizontalViewerScrollHandler) hc.removeEventListener('scroll', this.horizontalViewerScrollHandler);
                this.populateHorizontalViewer();
                this.setupHorizontalScrollTracking();
            }
        }

        document.body.classList.add('reading-mode', 'scroll-reading-mode');
        document.getElementById('readingModeBtn').classList.add('active');

        // Scroll current page into view centered
        const prefix = this.isVerticalViewer ? 'vertical' : 'horizontal';
        const pageEl = this._findViewerPageElement(prefix, currentPage);
        if (pageEl) {
            pageEl.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
        }

        // Update reading indicator
        this.updateReadingIndicator();

        // Setup auto-hide toolbar
        this.setupReadingModeToolbarAutoHide();
    }

    exitScrollReadingMode() {
        this.isReadingMode = false;
        const currentPage = this.getCurrentPageNumber();

        document.body.classList.remove('reading-mode', 'scroll-reading-mode');
        document.getElementById('readingModeBtn').classList.remove('active');

        // Teardown auto-hide
        this.teardownReadingModeToolbarAutoHide();

        // Restore spread mode if it was active before
        const pre = this.scrollReadingPreState;
        if (pre && !pre.forceSinglePage && this.forceSinglePage) {
            this.forceSinglePage = false;
            if (this.isVerticalViewer) {
                const vc = document.getElementById('verticalViewerContainer');
                if (this.verticalViewerScrollHandler) vc.removeEventListener('scroll', this.verticalViewerScrollHandler);
                this.populateVerticalViewer();
                this.setupVerticalScrollTracking();
            } else {
                const hc = document.getElementById('horizontalViewerContainer');
                if (this.horizontalViewerScrollHandler) hc.removeEventListener('scroll', this.horizontalViewerScrollHandler);
                this.populateHorizontalViewer();
                this.setupHorizontalScrollTracking();
            }
            // Scroll back to the page we were on
            const prefix = this.isVerticalViewer ? 'vertical' : 'horizontal';
            const pageEl = this._findViewerPageElement(prefix, currentPage);
            if (pageEl) {
                pageEl.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
            }
        }
        this.scrollReadingPreState = null;
    }

    setupReadingModeToolbarAutoHide() {
        const toolbar = document.querySelector('.bottom-toolbar');
        let hideTimeout = null;

        const showToolbar = () => {
            toolbar.classList.add('reading-toolbar-visible');
            clearTimeout(hideTimeout);
            hideTimeout = setTimeout(() => {
                if (this.isReadingMode) {
                    toolbar.classList.remove('reading-toolbar-visible');
                }
            }, 2500);
        };

        this._readingMouseMoveHandler = (e) => {
            const isTopLayout = document.body.classList.contains('layout-top-center') ||
                               document.body.classList.contains('layout-top-fullwidth');
            if (isTopLayout) {
                if (e.clientY < 80) showToolbar();
            } else {
                if (e.clientY > window.innerHeight - 80) showToolbar();
            }
        };

        this._readingTouchHandler = (e) => {
            const touch = e.touches[0];
            if (!touch) return;
            const isTopLayout = document.body.classList.contains('layout-top-center') ||
                               document.body.classList.contains('layout-top-fullwidth');
            if (isTopLayout) {
                if (touch.clientY < 80) showToolbar();
            } else {
                if (touch.clientY > window.innerHeight - 80) showToolbar();
            }
        };

        document.addEventListener('mousemove', this._readingMouseMoveHandler);
        document.addEventListener('touchstart', this._readingTouchHandler);

        // Show briefly on enter
        showToolbar();
    }

    teardownReadingModeToolbarAutoHide() {
        const toolbar = document.querySelector('.bottom-toolbar');
        toolbar.classList.remove('reading-toolbar-visible');

        if (this._readingMouseMoveHandler) {
            document.removeEventListener('mousemove', this._readingMouseMoveHandler);
            this._readingMouseMoveHandler = null;
        }
        if (this._readingTouchHandler) {
            document.removeEventListener('touchstart', this._readingTouchHandler);
            this._readingTouchHandler = null;
        }
    }

    updateReadingIndicator() {
        const indicator = document.getElementById('readingIndicator');
        if (!indicator || !this.numPages) return;

        if (document.body.classList.contains('scroll-reading-mode')) {
            indicator.textContent = `${this.getCurrentPageNumber()} / ${this.numPages}`;
            return;
        }

        if (this.forceSinglePage) {
            indicator.textContent = `${this.currentSinglePage} / ${this.numPages}`;
        } else {
            const flippedCount = this.pages.filter(p => p.flipped).length;
            if (flippedCount === this.minFlipped) {
                indicator.textContent = `1 / ${this.numPages}`;
            } else if (flippedCount === this.pages.length) {
                indicator.textContent = `${this.numPages} / ${this.numPages}`;
            } else {
                const leftPage = this.isEvenPageMode ? flippedCount * 2 - 1 : flippedCount * 2;
                indicator.textContent = `${leftPage} / ${this.numPages}`;
            }
        }
    }

    // ===== Vertical PDF Viewer =====
    toggleVerticalViewer() {
        if (this.isVerticalViewer) {
            this.exitVerticalViewer();
        } else {
            this.enterVerticalViewer();
        }
    }

    enterVerticalViewer() {
        // Exit other modes first
        if (this.isReadingMode) {
            this.isReadingMode = false;
            this.exitReadingMode();
        }
        if (this.isHorizontalViewer) {
            this.exitHorizontalViewer();
        }


        // Save current state
        this.verticalViewerPreState = {
            currentPage: this.getCurrentPageNumber(),
            forceSinglePage: this.forceSinglePage,
            isTilted: this.isTilted,
            zoomLevel: this.zoomLevel,
            zoomMode: this.zoomMode,
            rotationAngle: this.rotationAngle,
            panX: this.panX,
            panY: this.panY,
            handToolActive: this.handToolActive,
        };

        this.isVerticalViewer = true;
        this.setHandTool(true);
        document.body.classList.add('vertical-viewer');
        const vertOpt = document.getElementById('verticalViewerOption');
        if (vertOpt) vertOpt.classList.add('active');

        // Populate pages
        this.populateVerticalViewer();

        // Scroll to the current page
        const currentPage = this.verticalViewerPreState.currentPage;
        const container = document.getElementById('verticalViewerContainer');
        requestAnimationFrame(() => {
            const targetEl = document.getElementById(`vertical-page-${currentPage}`);
            if (targetEl) {
                targetEl.scrollIntoView({ behavior: 'instant', block: 'start' });
            }
        });

        // Set up scroll tracking
        this.setupVerticalScrollTracking();
        this._verticalCurrentPage = currentPage;

        // Reset zoom to 100% for vertical viewer and clear rotation
        this.zoomLevel = 100;
        this.zoomMode = null;
        this.rotationAngle = 0;
        this.updateZoomDisplay();
        this.updatePageIndicator();

        // Sync single page button state with current mode
        { const _spBtn = document.getElementById('singlePageBtn'); if (_spBtn) _spBtn.classList.toggle('active', this.forceSinglePage); }

        analytics.trackViewMode('vertical');
    }

    populateVerticalViewer() {
        const container = document.getElementById('verticalViewerContainer');
        container.innerHTML = '';

        // Single page mode: show pages one at a time
        if (this.forceSinglePage) {
            container.classList.remove('spread-mode');
            for (let i = 0; i < this.numPages; i++) {
                const pageWrapper = document.createElement('div');
                pageWrapper.className = 'vertical-viewer-page';
                pageWrapper.id = `vertical-page-${i + 1}`;
                pageWrapper.dataset.pageNum = i + 1;

                const dataUrl_vSingle = this.getPageDataURL(i);
                if (dataUrl_vSingle) {
                    const img = document.createElement('img');
                    img.src = dataUrl_vSingle;
                    img.alt = `Page ${i + 1}`;
                    img.loading = 'lazy';
                    pageWrapper.appendChild(img);
                }

                // Add PDF link overlays for this page
                this.createViewerPageLinks(i + 1, pageWrapper);

                // Add media overlays for this page
                this.createViewerPageMedia(i + 1, pageWrapper);

                // Add video overlays for this page
                this.createViewerPageVideo(i + 1, pageWrapper);
                this.createViewerPageNotes(i + 1, pageWrapper);
                this.createViewerConfigHotspots(i + 1, pageWrapper);

                const pageLabel = document.createElement('div');
                pageLabel.className = 'vertical-viewer-page-number';
                pageLabel.textContent = `${i + 1} / ${this.numPages}`;
                pageWrapper.appendChild(pageLabel);

                container.appendChild(pageWrapper);
            }
        } else {
            // Spread mode: show pages as 2-page spreads
            container.classList.add('spread-mode');

            if (this.isEvenPageMode) {
                // Even mode: pairs from start (1,2), (3,4), etc.
                for (let i = 0; i < this.numPages; i += 2) {
                    const leftPageNum = i + 1;
                    const rightPageNum = i + 2;
                    const hasRight = rightPageNum <= this.numPages;

                    if (hasRight) {
                        // Two-page spread
                        const spreadWrapper = document.createElement('div');
                        spreadWrapper.className = 'vertical-viewer-page vertical-viewer-spread';
                        spreadWrapper.id = `vertical-page-${leftPageNum}`;
                        spreadWrapper.dataset.pageNum = leftPageNum;

                        const spreadInner = document.createElement('div');
                        spreadInner.className = 'spread-pages';

                        // Left page
                        const leftPage = document.createElement('div');
                        leftPage.className = 'spread-page spread-page-left';
                        leftPage.dataset.pageNum = leftPageNum;
                        const leftDataUrl = this.getPageDataURL(i);
                        if (leftDataUrl) {
                            const img = document.createElement('img');
                            img.src = leftDataUrl;
                            img.alt = `Page ${leftPageNum}`;
                            img.loading = 'lazy';
                            leftPage.appendChild(img);
                        }
                        this.createViewerPageLinks(leftPageNum, leftPage);
                        this.createViewerPageMedia(leftPageNum, leftPage);
                        this.createViewerPageVideo(leftPageNum, leftPage);
                        this.createViewerPageNotes(leftPageNum, leftPage);
                        this.createViewerConfigHotspots(leftPageNum, leftPage);
                        spreadInner.appendChild(leftPage);

                        // Right page
                        const rightPage = document.createElement('div');
                        rightPage.className = 'spread-page spread-page-right';
                        rightPage.dataset.pageNum = rightPageNum;
                        const rightDataUrl = this.getPageDataURL(i + 1);
                        if (rightDataUrl) {
                            const img = document.createElement('img');
                            img.src = rightDataUrl;
                            img.alt = `Page ${rightPageNum}`;
                            img.loading = 'lazy';
                            rightPage.appendChild(img);
                        }
                        this.createViewerPageLinks(rightPageNum, rightPage);
                        this.createViewerPageMedia(rightPageNum, rightPage);
                        this.createViewerPageVideo(rightPageNum, rightPage);
                        this.createViewerPageNotes(rightPageNum, rightPage);
                        this.createViewerConfigHotspots(rightPageNum, rightPage);
                        spreadInner.appendChild(rightPage);

                        spreadWrapper.appendChild(spreadInner);

                        const spreadLabel = document.createElement('div');
                        spreadLabel.className = 'vertical-viewer-page-number';
                        spreadLabel.textContent = `${leftPageNum}-${rightPageNum} / ${this.numPages}`;
                        spreadWrapper.appendChild(spreadLabel);

                        container.appendChild(spreadWrapper);
                    } else {
                        // Last page alone (odd total count)
                        const lastWrapper = document.createElement('div');
                        lastWrapper.className = 'vertical-viewer-page vertical-viewer-spread';
                        lastWrapper.id = `vertical-page-${leftPageNum}`;
                        lastWrapper.dataset.pageNum = leftPageNum;

                        const lastInner = document.createElement('div');
                        lastInner.className = 'spread-page-single';
                        const lastDataUrl = this.getPageDataURL(i);
                        if (lastDataUrl) {
                            const img = document.createElement('img');
                            img.src = lastDataUrl;
                            img.alt = `Page ${leftPageNum}`;
                            img.loading = 'lazy';
                            lastInner.appendChild(img);
                        }
                        this.createViewerPageLinks(leftPageNum, lastInner);
                        this.createViewerPageMedia(leftPageNum, lastInner);
                        this.createViewerPageVideo(leftPageNum, lastInner);
                        this.createViewerPageNotes(leftPageNum, lastInner);
                        this.createViewerConfigHotspots(leftPageNum, lastInner);
                        lastWrapper.appendChild(lastInner);

                        const lastLabel = document.createElement('div');
                        lastLabel.className = 'vertical-viewer-page-number';
                        lastLabel.textContent = `${leftPageNum} / ${this.numPages}`;
                        lastWrapper.appendChild(lastLabel);
                        container.appendChild(lastWrapper);
                    }
                }
            } else {
                // Odd mode: First page (cover) shown alone
                const coverWrapper = document.createElement('div');
                coverWrapper.className = 'vertical-viewer-page vertical-viewer-spread';
                coverWrapper.id = `vertical-page-1`;
                coverWrapper.dataset.pageNum = 1;

                const coverInner = document.createElement('div');
                coverInner.className = 'spread-page-single';
                const coverDataUrl = this.getPageDataURL(0);
                if (coverDataUrl) {
                    const img = document.createElement('img');
                    img.src = coverDataUrl;
                    img.alt = 'Page 1';
                    img.loading = 'lazy';
                    coverInner.appendChild(img);
                }
                this.createViewerPageLinks(1, coverInner);
                this.createViewerPageMedia(1, coverInner);
                this.createViewerPageVideo(1, coverInner);
                this.createViewerPageNotes(1, coverInner);
                this.createViewerConfigHotspots(1, coverInner);
                coverWrapper.appendChild(coverInner);

                const coverLabel = document.createElement('div');
                coverLabel.className = 'vertical-viewer-page-number';
                coverLabel.textContent = `1 / ${this.numPages}`;
                coverWrapper.appendChild(coverLabel);
                container.appendChild(coverWrapper);

                // Middle pages as spreads (2 pages side by side)
                for (let i = 1; i < this.numPages - 1; i += 2) {
                    const spreadWrapper = document.createElement('div');
                    spreadWrapper.className = 'vertical-viewer-page vertical-viewer-spread';
                    spreadWrapper.id = `vertical-page-${i + 1}`;
                    spreadWrapper.dataset.pageNum = i + 1;

                    const spreadInner = document.createElement('div');
                    spreadInner.className = 'spread-pages';

                    // Left page
                    const leftPage = document.createElement('div');
                    leftPage.className = 'spread-page spread-page-left';
                    leftPage.dataset.pageNum = i + 1;
                    const leftDataUrl = this.getPageDataURL(i);
                    if (leftDataUrl) {
                        const img = document.createElement('img');
                        img.src = leftDataUrl;
                        img.alt = `Page ${i + 1}`;
                        img.loading = 'lazy';
                        leftPage.appendChild(img);
                    }
                    this.createViewerPageLinks(i + 1, leftPage);
                    this.createViewerPageMedia(i + 1, leftPage);
                    this.createViewerPageVideo(i + 1, leftPage);
                    this.createViewerPageNotes(i + 1, leftPage);
                    this.createViewerConfigHotspots(i + 1, leftPage);
                    spreadInner.appendChild(leftPage);

                    // Right page (if exists)
                    if (i + 1 < this.numPages) {
                        const rightPage = document.createElement('div');
                        rightPage.className = 'spread-page spread-page-right';
                        rightPage.dataset.pageNum = i + 2;
                        const rightDataUrl = this.getPageDataURL(i + 1);
                        if (rightDataUrl) {
                            const img = document.createElement('img');
                            img.src = rightDataUrl;
                            img.alt = `Page ${i + 2}`;
                            img.loading = 'lazy';
                            rightPage.appendChild(img);
                        }
                        this.createViewerPageLinks(i + 2, rightPage);
                        this.createViewerPageMedia(i + 2, rightPage);
                        this.createViewerPageVideo(i + 2, rightPage);
                        this.createViewerPageNotes(i + 2, rightPage);
                        this.createViewerConfigHotspots(i + 2, rightPage);
                        spreadInner.appendChild(rightPage);
                    }

                    spreadWrapper.appendChild(spreadInner);

                    const spreadLabel = document.createElement('div');
                    spreadLabel.className = 'vertical-viewer-page-number';
                    const rightPageNum = Math.min(i + 2, this.numPages);
                    spreadLabel.textContent = `${i + 1}-${rightPageNum} / ${this.numPages}`;
                    spreadWrapper.appendChild(spreadLabel);

                    container.appendChild(spreadWrapper);
                }

                // Last page alone if odd number of pages (excluding cover)
                if (this.numPages > 1 && this.numPages % 2 === 0) {
                    const lastWrapper = document.createElement('div');
                    lastWrapper.className = 'vertical-viewer-page vertical-viewer-spread';
                    lastWrapper.id = `vertical-page-${this.numPages}`;
                    lastWrapper.dataset.pageNum = this.numPages;

                    const lastInner = document.createElement('div');
                    lastInner.className = 'spread-page-single';
                    const lastDataUrl = this.getPageDataURL(this.numPages - 1);
                    if (lastDataUrl) {
                        const img = document.createElement('img');
                        img.src = lastDataUrl;
                        img.alt = `Page ${this.numPages}`;
                        img.loading = 'lazy';
                        lastInner.appendChild(img);
                    }
                    this.createViewerPageLinks(this.numPages, lastInner);
                    this.createViewerPageMedia(this.numPages, lastInner);
                    this.createViewerPageVideo(this.numPages, lastInner);
                    this.createViewerPageNotes(this.numPages, lastInner);
                    this.createViewerConfigHotspots(this.numPages, lastInner);
                    lastWrapper.appendChild(lastInner);

                    const lastLabel = document.createElement('div');
                    lastLabel.className = 'vertical-viewer-page-number';
                    lastLabel.textContent = `${this.numPages} / ${this.numPages}`;
                    lastWrapper.appendChild(lastLabel);
                    container.appendChild(lastWrapper);
                }
            }
        }
    }

    setupVerticalScrollTracking() {
        const container = document.getElementById('verticalViewerContainer');

        this.verticalViewerScrollHandler = () => {
            const pages = container.querySelectorAll('.vertical-viewer-page');
            const containerRect = container.getBoundingClientRect();
            const viewportTarget = containerRect.top + containerRect.height / 3;

            let closestPage = 1;
            let closestDistance = Infinity;

            pages.forEach((page) => {
                const rect = page.getBoundingClientRect();
                const pageCenter = rect.top + rect.height / 2;
                const distance = Math.abs(pageCenter - viewportTarget);
                if (distance < closestDistance) {
                    closestDistance = distance;
                    closestPage = parseInt(page.dataset.pageNum, 10);
                }
            });

            if (this._verticalCurrentPage !== closestPage) {
                this._verticalCurrentPage = closestPage;
                this.updatePageIndicator();
            }
        };

        container.addEventListener('scroll', this.verticalViewerScrollHandler, { passive: true });
        this._verticalDragHandlers = this.attachDragScroll(container);
    }

    exitVerticalViewer() {
        const visiblePage = this._verticalCurrentPage || this.verticalViewerPreState?.currentPage || 1;

        // Remove scroll listener
        const container = document.getElementById('verticalViewerContainer');
        if (this.verticalViewerScrollHandler) {
            container.removeEventListener('scroll', this.verticalViewerScrollHandler);
            this.verticalViewerScrollHandler = null;
        }
        if (this._verticalDragHandlers) {
            this._verticalDragHandlers.detach();
            this._verticalDragHandlers = null;
        }

        // Clear container
        container.innerHTML = '';

        // Remove body class
        document.body.classList.remove('vertical-viewer');
        const vertOpt = document.getElementById('verticalViewerOption');
        if (vertOpt) vertOpt.classList.remove('active');

        this.isVerticalViewer = false;
        this._verticalCurrentPage = null;
        this._destroyActiveMedia();

        // Restore previous state
        const pre = this.verticalViewerPreState;
        if (pre) {
            if (pre.forceSinglePage !== this.forceSinglePage) {
                this.toggleSinglePageMode();
            }

            if (pre.isTilted && !this.isTilted) {
                this.isTilted = true;
                this.animateBookTilt(-0.15);
                document.getElementById('tiltBtn').classList.add('active');
            }

            if (pre.rotationAngle !== this.rotationAngle) {
                this.rotationAngle = pre.rotationAngle;
                this.animateBookRotation(-this.rotationAngle);
            }

            if (pre.zoomMode) {
                this.setZoom(pre.zoomLevel, pre.zoomMode);
            } else {
                this.setZoom(pre.zoomLevel);
            }

            this.panX = pre.panX;
            this.panY = pre.panY;
            this.updateCameraPosition();

            this.setHandTool(pre.handToolActive);

            this.verticalViewerPreState = null;
        }

        // Renderer canvas may need resizing after container was display:none
        this.onResize();

        // Navigate flipbook to the page that was visible (silent to avoid flip sounds)
        this.goToPage(visiblePage, { silent: true });
        this.updatePageIndicator();

        analytics.trackViewMode('flipbook');
    }

    applyVerticalViewerZoom() {
        const container = document.getElementById('verticalViewerContainer');
        if (!container) return;
        // Base max-width is 800px at 100%. Scale proportionally.
        const scaledWidth = Math.round(800 * (this.zoomLevel / 100));
        const containerWidth = Math.round(900 * (this.zoomLevel / 100));
        container.querySelectorAll('.vertical-viewer-page').forEach(page => {
            page.style.maxWidth = `${containerWidth}px`;
        });
        container.querySelectorAll('.vertical-viewer-page img').forEach(img => {
            img.style.maxWidth = `${scaledWidth}px`;
        });
    }

    applyVerticalViewerRotation() {
        const container = document.getElementById('verticalViewerContainer');
        if (!container) return;
        const angle = this.rotationAngle % 360;
        container.querySelectorAll('.vertical-viewer-page img').forEach(img => {
            img.style.transform = angle ? `rotate(${angle}deg)` : '';
        });
    }

    // ===== Horizontal PDF Viewer =====
    toggleHorizontalViewer() {
        if (this.isHorizontalViewer) {
            this.exitHorizontalViewer();
        } else {
            this.enterHorizontalViewer();
        }
    }

    enterHorizontalViewer() {
        // Exit other modes first
        if (this.isReadingMode) {
            this.isReadingMode = false;
            this.exitReadingMode();
        }
        if (this.isVerticalViewer) {
            this.exitVerticalViewer();
        }


        // Save current state
        this.horizontalViewerPreState = {
            currentPage: this.getCurrentPageNumber(),
            forceSinglePage: this.forceSinglePage,
            isTilted: this.isTilted,
            zoomLevel: this.zoomLevel,
            zoomMode: this.zoomMode,
            rotationAngle: this.rotationAngle,
            panX: this.panX,
            panY: this.panY,
            handToolActive: this.handToolActive,
        };

        this.isHorizontalViewer = true;
        this.setHandTool(true);
        document.body.classList.add('horizontal-viewer');
        const horizOpt = document.getElementById('horizontalViewerOption');
        if (horizOpt) horizOpt.classList.add('active');

        // Populate pages
        this.populateHorizontalViewer();

        // Scroll to the current page
        const currentPage = this.horizontalViewerPreState.currentPage;
        const container = document.getElementById('horizontalViewerContainer');
        requestAnimationFrame(() => {
            const targetEl = document.getElementById(`horizontal-page-${currentPage}`);
            if (targetEl) {
                targetEl.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
            }
        });

        // Set up scroll tracking
        this.setupHorizontalScrollTracking();
        this._horizontalCurrentPage = currentPage;

        // Reset zoom and rotation for horizontal viewer
        this.zoomLevel = 100;
        this.zoomMode = null;
        this.rotationAngle = 0;
        this.updateZoomDisplay();
        this.updatePageIndicator();

        // Sync single page button state with current mode
        { const _spBtn = document.getElementById('singlePageBtn'); if (_spBtn) _spBtn.classList.toggle('active', this.forceSinglePage); }

        analytics.trackViewMode('horizontal');
    }

    populateHorizontalViewer() {
        const inner = document.getElementById('horizontalViewerInner');
        inner.innerHTML = '';

        // Single page mode: show pages one at a time
        if (this.forceSinglePage) {
            inner.classList.remove('spread-mode');
            for (let i = 0; i < this.numPages; i++) {
                const pageWrapper = document.createElement('div');
                pageWrapper.className = 'horizontal-viewer-page';
                pageWrapper.id = `horizontal-page-${i + 1}`;
                pageWrapper.dataset.pageNum = i + 1;

                const hSingleDataUrl = this.getPageDataURL(i);
                if (hSingleDataUrl) {
                    const img = document.createElement('img');
                    img.src = hSingleDataUrl;
                    img.alt = `Page ${i + 1}`;
                    img.loading = 'lazy';
                    pageWrapper.appendChild(img);
                }

                // Add PDF link overlays for this page
                this.createViewerPageLinks(i + 1, pageWrapper);

                // Add media overlays for this page
                this.createViewerPageMedia(i + 1, pageWrapper);

                // Add video overlays for this page
                this.createViewerPageVideo(i + 1, pageWrapper);
                this.createViewerPageNotes(i + 1, pageWrapper);
                this.createViewerConfigHotspots(i + 1, pageWrapper);

                const pageLabel = document.createElement('div');
                pageLabel.className = 'horizontal-viewer-page-number';
                pageLabel.textContent = `${i + 1} / ${this.numPages}`;
                pageWrapper.appendChild(pageLabel);

                inner.appendChild(pageWrapper);
            }
        } else {
            // Spread mode: show pages as 2-page spreads
            inner.classList.add('spread-mode');

            if (this.isEvenPageMode) {
                // Even mode: pairs from start (1,2), (3,4), etc.
                for (let i = 0; i < this.numPages; i += 2) {
                    const leftPageNum = i + 1;
                    const rightPageNum = i + 2;
                    const hasRight = rightPageNum <= this.numPages;

                    if (hasRight) {
                        // Two-page spread
                        const spreadWrapper = document.createElement('div');
                        spreadWrapper.className = 'horizontal-viewer-page horizontal-viewer-spread';
                        spreadWrapper.id = `horizontal-page-${leftPageNum}`;
                        spreadWrapper.dataset.pageNum = leftPageNum;

                        const spreadInner = document.createElement('div');
                        spreadInner.className = 'horizontal-spread-pages';

                        // Left page
                        const leftPage = document.createElement('div');
                        leftPage.className = 'horizontal-spread-page horizontal-spread-left';
                        leftPage.dataset.pageNum = leftPageNum;
                        const hLeftDataUrl = this.getPageDataURL(i);
                        if (hLeftDataUrl) {
                            const img = document.createElement('img');
                            img.src = hLeftDataUrl;
                            img.alt = `Page ${leftPageNum}`;
                            img.loading = 'lazy';
                            leftPage.appendChild(img);
                        }
                        this.createViewerPageLinks(leftPageNum, leftPage);
                        this.createViewerPageMedia(leftPageNum, leftPage);
                        this.createViewerPageVideo(leftPageNum, leftPage);
                        this.createViewerPageNotes(leftPageNum, leftPage);
                        this.createViewerConfigHotspots(leftPageNum, leftPage);
                        spreadInner.appendChild(leftPage);

                        // Right page
                        const rightPage = document.createElement('div');
                        rightPage.className = 'horizontal-spread-page horizontal-spread-right';
                        rightPage.dataset.pageNum = rightPageNum;
                        const hRightDataUrl = this.getPageDataURL(i + 1);
                        if (hRightDataUrl) {
                            const img = document.createElement('img');
                            img.src = hRightDataUrl;
                            img.alt = `Page ${rightPageNum}`;
                            img.loading = 'lazy';
                            rightPage.appendChild(img);
                        }
                        this.createViewerPageLinks(rightPageNum, rightPage);
                        this.createViewerPageMedia(rightPageNum, rightPage);
                        this.createViewerPageVideo(rightPageNum, rightPage);
                        this.createViewerPageNotes(rightPageNum, rightPage);
                        this.createViewerConfigHotspots(rightPageNum, rightPage);
                        spreadInner.appendChild(rightPage);

                        spreadWrapper.appendChild(spreadInner);

                        const spreadLabel = document.createElement('div');
                        spreadLabel.className = 'horizontal-viewer-page-number';
                        spreadLabel.textContent = `${leftPageNum}-${rightPageNum} / ${this.numPages}`;
                        spreadWrapper.appendChild(spreadLabel);

                        inner.appendChild(spreadWrapper);
                    } else {
                        // Last page alone (odd total count)
                        const lastWrapper = document.createElement('div');
                        lastWrapper.className = 'horizontal-viewer-page horizontal-viewer-spread';
                        lastWrapper.id = `horizontal-page-${leftPageNum}`;
                        lastWrapper.dataset.pageNum = leftPageNum;

                        const lastInner = document.createElement('div');
                        lastInner.className = 'horizontal-spread-single';
                        const hLastDataUrl = this.getPageDataURL(i);
                        if (hLastDataUrl) {
                            const img = document.createElement('img');
                            img.src = hLastDataUrl;
                            img.alt = `Page ${leftPageNum}`;
                            img.loading = 'lazy';
                            lastInner.appendChild(img);
                        }
                        this.createViewerPageLinks(leftPageNum, lastInner);
                        this.createViewerPageMedia(leftPageNum, lastInner);
                        this.createViewerPageVideo(leftPageNum, lastInner);
                        this.createViewerPageNotes(leftPageNum, lastInner);
                        this.createViewerConfigHotspots(leftPageNum, lastInner);
                        lastWrapper.appendChild(lastInner);

                        const lastLabel = document.createElement('div');
                        lastLabel.className = 'horizontal-viewer-page-number';
                        lastLabel.textContent = `${leftPageNum} / ${this.numPages}`;
                        lastWrapper.appendChild(lastLabel);
                        inner.appendChild(lastWrapper);
                    }
                }
            } else {
                // Odd mode: First page (cover) shown alone
                const coverWrapper = document.createElement('div');
                coverWrapper.className = 'horizontal-viewer-page horizontal-viewer-spread';
                coverWrapper.id = `horizontal-page-1`;
                coverWrapper.dataset.pageNum = 1;

                const coverInner = document.createElement('div');
                coverInner.className = 'horizontal-spread-single';
                const hCoverDataUrl = this.getPageDataURL(0);
                if (hCoverDataUrl) {
                    const img = document.createElement('img');
                    img.src = hCoverDataUrl;
                    img.alt = 'Page 1';
                    img.loading = 'lazy';
                    coverInner.appendChild(img);
                }
                this.createViewerPageLinks(1, coverInner);
                this.createViewerPageMedia(1, coverInner);
                this.createViewerPageVideo(1, coverInner);
                this.createViewerPageNotes(1, coverInner);
                this.createViewerConfigHotspots(1, coverInner);
                coverWrapper.appendChild(coverInner);

                const coverLabel = document.createElement('div');
                coverLabel.className = 'horizontal-viewer-page-number';
                coverLabel.textContent = `1 / ${this.numPages}`;
                coverWrapper.appendChild(coverLabel);
                inner.appendChild(coverWrapper);

                // Middle pages as spreads (2 pages side by side)
                for (let i = 1; i < this.numPages - 1; i += 2) {
                    const spreadWrapper = document.createElement('div');
                    spreadWrapper.className = 'horizontal-viewer-page horizontal-viewer-spread';
                    spreadWrapper.id = `horizontal-page-${i + 1}`;
                    spreadWrapper.dataset.pageNum = i + 1;

                    const spreadInner = document.createElement('div');
                    spreadInner.className = 'horizontal-spread-pages';

                    // Left page
                    const leftPage = document.createElement('div');
                    leftPage.className = 'horizontal-spread-page horizontal-spread-left';
                    leftPage.dataset.pageNum = i + 1;
                    const hLeftDataUrl = this.getPageDataURL(i);
                    if (hLeftDataUrl) {
                        const img = document.createElement('img');
                        img.src = hLeftDataUrl;
                        img.alt = `Page ${i + 1}`;
                        img.loading = 'lazy';
                        leftPage.appendChild(img);
                    }
                    this.createViewerPageLinks(i + 1, leftPage);
                    this.createViewerPageMedia(i + 1, leftPage);
                    this.createViewerPageVideo(i + 1, leftPage);
                    this.createViewerPageNotes(i + 1, leftPage);
                    this.createViewerConfigHotspots(i + 1, leftPage);
                    spreadInner.appendChild(leftPage);

                    // Right page (if exists)
                    if (i + 1 < this.numPages) {
                        const rightPage = document.createElement('div');
                        rightPage.className = 'horizontal-spread-page horizontal-spread-right';
                        rightPage.dataset.pageNum = i + 2;
                        const hRightDataUrl = this.getPageDataURL(i + 1);
                        if (hRightDataUrl) {
                            const img = document.createElement('img');
                            img.src = hRightDataUrl;
                            img.alt = `Page ${i + 2}`;
                            img.loading = 'lazy';
                            rightPage.appendChild(img);
                        }
                        this.createViewerPageLinks(i + 2, rightPage);
                        this.createViewerPageMedia(i + 2, rightPage);
                        this.createViewerPageVideo(i + 2, rightPage);
                        this.createViewerPageNotes(i + 2, rightPage);
                        this.createViewerConfigHotspots(i + 2, rightPage);
                        spreadInner.appendChild(rightPage);
                    }

                    spreadWrapper.appendChild(spreadInner);

                    const spreadLabel = document.createElement('div');
                    spreadLabel.className = 'horizontal-viewer-page-number';
                    const rightPageNum = Math.min(i + 2, this.numPages);
                    spreadLabel.textContent = `${i + 1}-${rightPageNum} / ${this.numPages}`;
                    spreadWrapper.appendChild(spreadLabel);

                    inner.appendChild(spreadWrapper);
                }

                // Last page alone if odd number of pages (excluding cover)
                if (this.numPages > 1 && this.numPages % 2 === 0) {
                    const lastWrapper = document.createElement('div');
                    lastWrapper.className = 'horizontal-viewer-page horizontal-viewer-spread';
                    lastWrapper.id = `horizontal-page-${this.numPages}`;
                    lastWrapper.dataset.pageNum = this.numPages;

                    const lastInner = document.createElement('div');
                    lastInner.className = 'horizontal-spread-single';
                    const hLastDataUrl = this.getPageDataURL(this.numPages - 1);
                    if (hLastDataUrl) {
                        const img = document.createElement('img');
                        img.src = hLastDataUrl;
                        img.alt = `Page ${this.numPages}`;
                        img.loading = 'lazy';
                        lastInner.appendChild(img);
                    }
                    this.createViewerPageLinks(this.numPages, lastInner);
                    this.createViewerPageMedia(this.numPages, lastInner);
                    this.createViewerPageVideo(this.numPages, lastInner);
                    this.createViewerPageNotes(this.numPages, lastInner);
                    this.createViewerConfigHotspots(this.numPages, lastInner);
                    lastWrapper.appendChild(lastInner);

                    const lastLabel = document.createElement('div');
                    lastLabel.className = 'horizontal-viewer-page-number';
                    lastLabel.textContent = `${this.numPages} / ${this.numPages}`;
                    lastWrapper.appendChild(lastLabel);
                    inner.appendChild(lastWrapper);
                }
            }
        }
    }

    setupHorizontalScrollTracking() {
        const container = document.getElementById('horizontalViewerContainer');

        this.horizontalViewerScrollHandler = () => {
            const pages = container.querySelectorAll('.horizontal-viewer-page');
            const containerRect = container.getBoundingClientRect();
            const viewportCenter = containerRect.left + containerRect.width / 2;

            let closestPage = 1;
            let closestDistance = Infinity;

            pages.forEach((page) => {
                const rect = page.getBoundingClientRect();
                const pageCenter = rect.left + rect.width / 2;
                const distance = Math.abs(pageCenter - viewportCenter);
                if (distance < closestDistance) {
                    closestDistance = distance;
                    closestPage = parseInt(page.dataset.pageNum, 10);
                }
            });

            if (this._horizontalCurrentPage !== closestPage) {
                this._horizontalCurrentPage = closestPage;
                this.updatePageIndicator();
            }
        };

        container.addEventListener('scroll', this.horizontalViewerScrollHandler, { passive: true });
        this._horizontalDragHandlers = this.attachDragScroll(container);
    }

    exitHorizontalViewer() {
        const visiblePage = this._horizontalCurrentPage || this.horizontalViewerPreState?.currentPage || 1;

        // Remove scroll listener
        const container = document.getElementById('horizontalViewerContainer');
        if (this.horizontalViewerScrollHandler) {
            container.removeEventListener('scroll', this.horizontalViewerScrollHandler);
            this.horizontalViewerScrollHandler = null;
        }
        if (this._horizontalDragHandlers) {
            this._horizontalDragHandlers.detach();
            this._horizontalDragHandlers = null;
        }

        // Clear container
        document.getElementById('horizontalViewerInner').innerHTML = '';

        // Remove body class
        document.body.classList.remove('horizontal-viewer');
        const horizOpt = document.getElementById('horizontalViewerOption');
        if (horizOpt) horizOpt.classList.remove('active');

        this.isHorizontalViewer = false;
        this._horizontalCurrentPage = null;
        this._destroyActiveMedia();

        // Restore previous state
        const pre = this.horizontalViewerPreState;
        if (pre) {
            if (pre.forceSinglePage !== this.forceSinglePage) {
                this.toggleSinglePageMode();
            }

            if (pre.isTilted && !this.isTilted) {
                this.isTilted = true;
                this.animateBookTilt(-0.15);
                document.getElementById('tiltBtn').classList.add('active');
            }

            if (pre.rotationAngle !== this.rotationAngle) {
                this.rotationAngle = pre.rotationAngle;
                this.animateBookRotation(-this.rotationAngle);
            }

            if (pre.zoomMode) {
                this.setZoom(pre.zoomLevel, pre.zoomMode);
            } else {
                this.setZoom(pre.zoomLevel);
            }

            this.panX = pre.panX;
            this.panY = pre.panY;
            this.updateCameraPosition();

            this.setHandTool(pre.handToolActive);

            this.horizontalViewerPreState = null;
        }

        // Renderer canvas may need resizing after container was display:none
        this.onResize();

        // Navigate flipbook to the page that was visible (silent to avoid flip sounds)
        this.goToPage(visiblePage, { silent: true });
        this.updatePageIndicator();

        analytics.trackViewMode('flipbook');
    }

    applyHorizontalViewerZoom() {
        const container = document.getElementById('horizontalViewerInner');
        if (!container) return;
        // Base height is calc(100vh - 180px) at 100%, max 800px. Scale proportionally.
        const scaledHeight = Math.round(800 * (this.zoomLevel / 100));
        container.querySelectorAll('.horizontal-viewer-page img').forEach(img => {
            img.style.maxHeight = `${scaledHeight}px`;
        });
    }

    applyHorizontalViewerRotation() {
        const container = document.getElementById('horizontalViewerInner');
        if (!container) return;
        const angle = this.rotationAngle % 360;
        container.querySelectorAll('.horizontal-viewer-page img').forEach(img => {
            img.style.transform = angle ? `rotate(${angle}deg)` : '';
        });
    }

    // ==========================================
    // Form Viewer Mode (PDF Forms)
    // ==========================================

        enterFormViewer() {}
        exitFormViewer() {}
        toggleFormViewer() {}

    async printFilledPDF(pageNumbers = null) {
        this.showNotification(t('notify.printPreparing'));

        // Determine which pages to print
        const pagesToPrint = pageNumbers && pageNumbers.length > 0
            ? pageNumbers
            : Array.from({ length: this.numPages }, (_, i) => i + 1);

        // Create a print window using cached page images (fast!)
        const printWindow = window.open('', '_blank');
        if (!printWindow) {
            this.showNotification(t('notify.allowPopups'));
            return;
        }

        printWindow.document.write(`
            <!DOCTYPE html>
            <html>
            <head>
                <title>Print PDF</title>
                <style>
                    * { margin: 0; padding: 0; box-sizing: border-box; }
                    body { background: white; }
                    .page {
                        page-break-after: always;
                        position: relative;
                        width: 100%;
                        max-width: 100%;
                    }
                    .page:last-child { page-break-after: auto; }
                    .page img {
                        display: block;
                        width: 100%;
                        height: auto;
                    }
                    .page canvas {
                        position: absolute;
                        top: 0;
                        left: 0;
                        width: 100%;
                        height: 100%;
                        pointer-events: none;
                    }
                    @media print {
                        .page { page-break-after: always; }
                        .page:last-child { page-break-after: auto; }
                    }
                </style>
            </head>
            <body>
                <div id="pages"></div>
            </body>
            </html>
        `);
        printWindow.document.close();

        const pagesContainer = printWindow.document.getElementById('pages');

        try {
            // Pre-load any signature images
            const imageCache = new Map();
            if (this.formAnnotations && this.formAnnotations.length > 0) {
                for (const annot of this.formAnnotations) {
                    if ((annot.type === 'signature' || annot.type === 'initials') && annot.dataURL) {
                        if (!imageCache.has(annot.dataURL)) {
                            const img = await this.loadImage(annot.dataURL);
                            if (img) imageCache.set(annot.dataURL, img);
                        }
                    }
                }
            }

            // Use cached page images for fast rendering (only selected pages)
            for (const pageNum of pagesToPrint) {
                const i = pageNum - 1;
                const pageDiv = printWindow.document.createElement('div');
                pageDiv.className = 'page';

                // Use cached page image (already rendered with fonts)
                const printDataUrl = this.getPageDataURL(i);
                if (printDataUrl) {
                    const img = printWindow.document.createElement('img');
                    img.src = printDataUrl;
                    pageDiv.appendChild(img);

                    // Add overlay canvas for annotations if there are any for this page
                    const pageAnnotations = this.formAnnotations ?
                        this.formAnnotations.filter(a => a.pageNum === pageNum) : [];

                    if (pageAnnotations.length > 0) {
                        // Create overlay canvas for annotations
                        const canvas = printWindow.document.createElement('canvas');
                        // Match the source image dimensions
                        const tempImg = await this.loadImage(printDataUrl);
                        if (tempImg) {
                            canvas.width = tempImg.width;
                            canvas.height = tempImg.height;
                            const ctx = canvas.getContext('2d');

                            // Scale factor: form viewer uses 1.5, cached images use CONFIG.renderScale (2)
                            const formViewerScale = 1.5;
                            const cachedScale = CONFIG.renderScale;
                            const scaleRatio = cachedScale / formViewerScale;

                            this.drawFormAnnotationsOnCanvas(ctx, cachedScale, pageNum, imageCache, scaleRatio);
                            pageDiv.appendChild(canvas);
                        }
                    }
                }

                pagesContainer.appendChild(pageDiv);
            }

            // Trigger print after a brief delay
            setTimeout(() => {
                printWindow.focus();
                printWindow.print();
            }, 300);

        } catch (err) {
            console.error('Error printing filled PDF:', err);
            printWindow.document.body.innerHTML = '<p style="padding: 20px; color: red;">Error generating print preview: ' + err.message + '</p>';
        }
    }

    loadImage(src) {
        return new Promise((resolve) => {
            const img = new Image();
            img.onload = () => resolve(img);
            img.onerror = () => resolve(null);
            img.src = src;
        });
    }

    drawFormAnnotationsOnCanvas(ctx, scale, pageNum, imageCache, customScaleRatio = null) {
        // Draw custom annotations (signatures, text, checkmarks, etc.)
        if (!this.formAnnotations || this.formAnnotations.length === 0) return;

        ctx.save();

        // Calculate scale ratio between form viewer (1.5) and target scale
        const formViewerScale = 1.5;
        const scaleRatio = customScaleRatio !== null ? customScaleRatio : (scale / formViewerScale);

        // Get annotations for this page
        const pageAnnotations = this.formAnnotations.filter(a => a.pageNum === pageNum);

        for (const annot of pageAnnotations) {
            const x = annot.x * scaleRatio;
            const y = annot.y * scaleRatio;

            if (annot.type === 'signature' || annot.type === 'initials') {
                // Draw signature/initials image from cache
                const img = imageCache.get(annot.dataURL);
                if (img) {
                    ctx.drawImage(img, x, y, annot.width * scaleRatio, annot.height * scaleRatio);
                }
            } else if (annot.type === 'text') {
                ctx.font = `${14 * scaleRatio}px sans-serif`;
                ctx.fillStyle = '#000000';
                ctx.fillText(annot.text, x, y + 14 * scaleRatio);
            } else if (annot.type === 'date') {
                ctx.font = `${12 * scaleRatio}px sans-serif`;
                ctx.fillStyle = '#000000';
                ctx.fillText(annot.text, x, y + 12 * scaleRatio);
            } else if (annot.type === 'checkmark') {
                ctx.strokeStyle = '#000000';
                ctx.lineWidth = 2.5 * scaleRatio;
                ctx.beginPath();
                ctx.moveTo(x, y + 12 * scaleRatio);
                ctx.lineTo(x + 8 * scaleRatio, y + 20 * scaleRatio);
                ctx.lineTo(x + 24 * scaleRatio, y + 4 * scaleRatio);
                ctx.stroke();
            } else if (annot.type === 'highlight') {
                ctx.fillStyle = 'rgba(255, 255, 0, 0.4)';
                ctx.fillRect(x, y, annot.width * scaleRatio, annot.height * scaleRatio);
            }
        }

        ctx.restore();
    }

    // ==========================================
    // Form Toolbar Methods
    // ==========================================

    setupFormToolbar() {
        // Tool selection
        const toolButtons = document.querySelectorAll('.form-tool-btn');
        toolButtons.forEach(btn => {
            btn.addEventListener('click', () => {
                const tool = btn.dataset.tool;
                this.setFormTool(tool);
            });
        });

        // Clear all button
        document.getElementById('formClearAllBtn').addEventListener('click', () => {
            this.clearAllFormData();
        });

        // Signature modal
        this.setupSignatureModal();
    }

    setFormTool(tool) {
        this.formActiveTool = tool;

        // Update button states
        document.querySelectorAll('.form-tool-btn').forEach(btn => {
            btn.classList.toggle('active', btn.dataset.tool === tool);
        });

        // Handle tool-specific actions
        if (tool === 'signature') {
            if (this.signatureDataURL) {
                // Use saved signature
                this.showNotification(t('notify.placeSignature'));
            } else {
                this.openSignatureModal('signature');
            }
        } else if (tool === 'initials') {
            if (this.initialsDataURL) {
                this.showNotification(t('notify.placeInitials'));
            } else {
                this.openSignatureModal('initials');
            }
        } else if (tool === 'date') {
            this.showNotification(t('notify.placeDate'));
        } else if (tool === 'text') {
            this.showNotification(t('notify.placeText'));
        } else if (tool === 'checkmark') {
            this.showNotification(t('notify.placeCheckmark'));
        } else if (tool === 'highlight') {
            this.showNotification(t('notify.placeHighlight'));
        }
    }

    setupSignatureModal() {
        const modal = document.getElementById('signatureModal');
        const canvas = document.getElementById('signatureCanvas');
        const ctx = canvas.getContext('2d');
        this.signatureCtx = ctx;

        // Clear canvas
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        let isDrawing = false;
        let lastX = 0;
        let lastY = 0;

        const getPos = (e) => {
            const rect = canvas.getBoundingClientRect();
            const scaleX = canvas.width / rect.width;
            const scaleY = canvas.height / rect.height;
            if (e.touches) {
                return {
                    x: (e.touches[0].clientX - rect.left) * scaleX,
                    y: (e.touches[0].clientY - rect.top) * scaleY
                };
            }
            return {
                x: (e.clientX - rect.left) * scaleX,
                y: (e.clientY - rect.top) * scaleY
            };
        };

        const startDrawing = (e) => {
            isDrawing = true;
            const pos = getPos(e);
            lastX = pos.x;
            lastY = pos.y;
        };

        const draw = (e) => {
            if (!isDrawing) return;
            e.preventDefault();
            const pos = getPos(e);

            ctx.beginPath();
            ctx.moveTo(lastX, lastY);
            ctx.lineTo(pos.x, pos.y);
            ctx.strokeStyle = '#000000';
            ctx.lineWidth = 2;
            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';
            ctx.stroke();

            lastX = pos.x;
            lastY = pos.y;
        };

        const stopDrawing = () => {
            isDrawing = false;
        };

        canvas.addEventListener('mousedown', startDrawing);
        canvas.addEventListener('mousemove', draw);
        canvas.addEventListener('mouseup', stopDrawing);
        canvas.addEventListener('mouseleave', stopDrawing);
        canvas.addEventListener('touchstart', startDrawing, { passive: false });
        canvas.addEventListener('touchmove', draw, { passive: false });
        canvas.addEventListener('touchend', stopDrawing);

        // Clear button
        document.getElementById('signatureClearBtn').addEventListener('click', () => {
            ctx.fillStyle = '#ffffff';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
        });

        // Cancel button
        document.getElementById('signatureCancelBtn').addEventListener('click', () => {
            this.closeSignatureModal();
        });

        // Save button
        document.getElementById('signatureSaveBtn').addEventListener('click', () => {
            this.saveSignature();
        });
    }

    openSignatureModal(type) {
        this._signatureType = type;
        const modal = document.getElementById('signatureModal');
        const title = document.getElementById('signatureModalTitle');
        const canvas = document.getElementById('signatureCanvas');
        const ctx = canvas.getContext('2d');

        title.textContent = type === 'initials' ? 'Draw Your Initials' : 'Draw Your Signature';

        // Clear canvas
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        modal.classList.add('active');
    }

    closeSignatureModal() {
        const modal = document.getElementById('signatureModal');
        modal.classList.remove('active');
        this.setFormTool('select');
    }

    saveSignature() {
        const canvas = document.getElementById('signatureCanvas');

        // Trim the signature to remove whitespace
        const trimmedDataURL = this.trimSignatureCanvas(canvas);

        if (this._signatureType === 'initials') {
            this.initialsDataURL = trimmedDataURL;
            this.showNotification(t('notify.initialsSaved'));
        } else {
            this.signatureDataURL = trimmedDataURL;
            this.showNotification(t('notify.signatureSaved'));
        }

        this.closeSignatureModal();
        this.setFormTool(this._signatureType);
    }

    trimSignatureCanvas(canvas) {
        const ctx = canvas.getContext('2d');
        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        const data = imageData.data;

        let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
        let hasContent = false;

        for (let y = 0; y < canvas.height; y++) {
            for (let x = 0; x < canvas.width; x++) {
                const idx = (y * canvas.width + x) * 4;
                // Check if pixel is not white
                if (data[idx] < 250 || data[idx + 1] < 250 || data[idx + 2] < 250) {
                    hasContent = true;
                    minX = Math.min(minX, x);
                    minY = Math.min(minY, y);
                    maxX = Math.max(maxX, x);
                    maxY = Math.max(maxY, y);
                }
            }
        }

        if (!hasContent) {
            return canvas.toDataURL();
        }

        // Add padding
        const padding = 10;
        minX = Math.max(0, minX - padding);
        minY = Math.max(0, minY - padding);
        maxX = Math.min(canvas.width, maxX + padding);
        maxY = Math.min(canvas.height, maxY + padding);

        // Create trimmed canvas
        const trimmedCanvas = document.createElement('canvas');
        trimmedCanvas.width = maxX - minX;
        trimmedCanvas.height = maxY - minY;
        const trimmedCtx = trimmedCanvas.getContext('2d');

        // Make background transparent
        trimmedCtx.clearRect(0, 0, trimmedCanvas.width, trimmedCanvas.height);

        // Draw only the signature part
        trimmedCtx.drawImage(canvas, minX, minY, maxX - minX, maxY - minY, 0, 0, maxX - minX, maxY - minY);

        return trimmedCanvas.toDataURL('image/png');
    }

    handleFormPageClick(e, pageWrapper) {
        if (this.formActiveTool === 'select') return;
        if (this.formActiveTool === 'highlight') return; // Handled by drag

        const rect = pageWrapper.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;
        const pageNum = parseInt(pageWrapper.dataset.pageNum, 10);

        if (this.formActiveTool === 'signature') {
            if (!this.signatureDataURL) {
                this.openSignatureModal('signature');
                return;
            }
            this.addSignatureAnnotation(pageNum, x, y, this.signatureDataURL, 'signature');
        } else if (this.formActiveTool === 'initials') {
            if (!this.initialsDataURL) {
                this.openSignatureModal('initials');
                return;
            }
            this.addSignatureAnnotation(pageNum, x, y, this.initialsDataURL, 'initials');
        } else if (this.formActiveTool === 'text') {
            this.addTextAnnotation(pageNum, x, y);
        } else if (this.formActiveTool === 'date') {
            this.addDateAnnotation(pageNum, x, y);
        } else if (this.formActiveTool === 'checkmark') {
            this.addCheckmarkAnnotation(pageNum, x, y);
        }
    }

    setupHighlightDrag(pageWrapper, pageNum) {
        let isDragging = false;
        let startX, startY;
        let highlightPreview = null;

        const onMouseDown = (e) => {
            if (this.formActiveTool !== 'highlight') return;
            if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
            if (e.target.closest('.annotationLayer section')) return;
            if (e.target.closest('.form-annotation')) return;

            isDragging = true;
            const rect = pageWrapper.getBoundingClientRect();
            startX = e.clientX - rect.left;
            startY = e.clientY - rect.top;

            // Create preview element
            highlightPreview = document.createElement('div');
            highlightPreview.style.cssText = `
                position: absolute;
                background: rgba(255, 255, 0, 0.4);
                border: 2px dashed rgba(200, 180, 0, 0.8);
                pointer-events: none;
                z-index: 50;
            `;
            highlightPreview.style.left = startX + 'px';
            highlightPreview.style.top = startY + 'px';
            highlightPreview.style.width = '0px';
            highlightPreview.style.height = '0px';
            pageWrapper.appendChild(highlightPreview);

            e.preventDefault();
        };

        const onMouseMove = (e) => {
            if (!isDragging || !highlightPreview) return;

            const rect = pageWrapper.getBoundingClientRect();
            const currentX = e.clientX - rect.left;
            const currentY = e.clientY - rect.top;

            const x = Math.min(startX, currentX);
            const y = Math.min(startY, currentY);
            const width = Math.abs(currentX - startX);
            const height = Math.abs(currentY - startY);

            highlightPreview.style.left = x + 'px';
            highlightPreview.style.top = y + 'px';
            highlightPreview.style.width = width + 'px';
            highlightPreview.style.height = height + 'px';
        };

        const onMouseUp = (e) => {
            if (!isDragging) return;
            isDragging = false;

            if (highlightPreview) {
                const rect = pageWrapper.getBoundingClientRect();
                const currentX = e.clientX - rect.left;
                const currentY = e.clientY - rect.top;

                const x = Math.min(startX, currentX);
                const y = Math.min(startY, currentY);
                const width = Math.abs(currentX - startX);
                const height = Math.abs(currentY - startY);

                // Remove preview
                highlightPreview.remove();
                highlightPreview = null;

                // Only create highlight if dragged a minimum size
                if (width > 10 && height > 5) {
                    this.addHighlightAnnotation(pageNum, x, y, width, height);
                    this._justFinishedHighlightDrag = true;
                }
            }
        };

        pageWrapper.addEventListener('mousedown', onMouseDown);
        pageWrapper.addEventListener('mousemove', onMouseMove);
        pageWrapper.addEventListener('mouseup', onMouseUp);
        pageWrapper.addEventListener('mouseleave', onMouseUp);
    }

    addHighlightAnnotation(pageNum, x, y, width, height) {
        const annotation = {
            id: Date.now(),
            type: 'highlight',
            pageNum: pageNum,
            x: x,
            y: y,
            width: width,
            height: height
        };

        this.formAnnotations.push(annotation);
        this.renderFormAnnotation(annotation);
    }

    addSignatureAnnotation(pageNum, x, y, dataURL, type) {
        const annotation = {
            id: Date.now(),
            type: type,
            pageNum: pageNum,
            x: x,
            y: y,
            width: type === 'initials' ? 60 : 150,
            height: type === 'initials' ? 40 : 60,
            dataURL: dataURL
        };

        this.formAnnotations.push(annotation);
        this.renderFormAnnotation(annotation);
    }

    addTextAnnotation(pageNum, x, y) {
        const text = prompt('Enter text:');
        if (!text) return;

        const annotation = {
            id: Date.now(),
            type: 'text',
            pageNum: pageNum,
            x: x,
            y: y,
            text: text
        };

        this.formAnnotations.push(annotation);
        this.renderFormAnnotation(annotation);
    }

    addDateAnnotation(pageNum, x, y) {
        const today = new Date();
        const dateStr = today.toLocaleDateString('en-US', {
            year: 'numeric',
            month: '2-digit',
            day: '2-digit'
        });

        const annotation = {
            id: Date.now(),
            type: 'date',
            pageNum: pageNum,
            x: x,
            y: y,
            text: dateStr
        };

        this.formAnnotations.push(annotation);
        this.renderFormAnnotation(annotation);
    }

    addCheckmarkAnnotation(pageNum, x, y) {
        const annotation = {
            id: Date.now(),
            type: 'checkmark',
            pageNum: pageNum,
            x: x - 12,
            y: y - 12
        };

        this.formAnnotations.push(annotation);
        this.renderFormAnnotation(annotation);
    }

    renderFormAnnotation(annotation) {
        const pageWrapper = document.getElementById(`form-page-${annotation.pageNum}`);
        if (!pageWrapper) return;

        const el = document.createElement('div');
        el.className = `form-annotation ${annotation.type}`;
        el.dataset.annotId = annotation.id;
        el.style.left = annotation.x + 'px';
        el.style.top = annotation.y + 'px';

        if (annotation.type === 'signature' || annotation.type === 'initials') {
            const img = document.createElement('img');
            img.src = annotation.dataURL;
            img.style.width = annotation.width + 'px';
            img.style.height = annotation.height + 'px';
            el.appendChild(img);
            el.style.width = annotation.width + 'px';
            el.style.height = annotation.height + 'px';
        } else if (annotation.type === 'text' || annotation.type === 'date') {
            el.textContent = annotation.text;
            el.classList.add('text-annot');
        } else if (annotation.type === 'checkmark') {
            el.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>';
        } else if (annotation.type === 'highlight') {
            el.style.width = annotation.width + 'px';
            el.style.height = annotation.height + 'px';
            el.style.pointerEvents = 'auto'; // Enable pointer events for delete
            el.style.cursor = 'pointer';
        }

        // Delete button
        const deleteBtn = document.createElement('button');
        deleteBtn.className = 'delete-annot';
        deleteBtn.innerHTML = '×';
        deleteBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            this.removeFormAnnotation(annotation.id);
        });
        el.appendChild(deleteBtn);

        // Make draggable
        this.makeAnnotationDraggable(el, annotation);

        pageWrapper.appendChild(el);
    }

    makeAnnotationDraggable(el, annotation) {
        let isDragging = false;
        let startX, startY, origX, origY;

        const onMouseDown = (e) => {
            if (e.target.classList.contains('delete-annot')) return;
            isDragging = true;
            startX = e.clientX;
            startY = e.clientY;
            origX = annotation.x;
            origY = annotation.y;
            el.style.zIndex = '100';
            e.preventDefault();
        };

        const onMouseMove = (e) => {
            if (!isDragging) return;
            const dx = e.clientX - startX;
            const dy = e.clientY - startY;
            annotation.x = origX + dx;
            annotation.y = origY + dy;
            el.style.left = annotation.x + 'px';
            el.style.top = annotation.y + 'px';
        };

        const onMouseUp = () => {
            isDragging = false;
            el.style.zIndex = '';
        };

        el.addEventListener('mousedown', onMouseDown);
        document.addEventListener('mousemove', onMouseMove);
        document.addEventListener('mouseup', onMouseUp);
    }

    removeFormAnnotation(id) {
        this.formAnnotations = this.formAnnotations.filter(a => a.id !== id);
        const el = document.querySelector(`[data-annot-id="${id}"]`);
        if (el) el.remove();
    }

    clearAllFormData() {
        if (!confirm('Clear all form data and annotations?')) return;

        // Clear form field values
        this.annotationStorage.clear();

        // Clear custom annotations
        this.formAnnotations = [];

        // Clear saved signatures
        this.signatureDataURL = null;
        this.initialsDataURL = null;

        // Re-render the form viewer
        this.populateFormViewer();
        this.showNotification(t('notify.formCleared'));
    }

    // ==========================================
    // Flipbook Style Methods
    // ==========================================

    setFlipbookStyle(style) {
        if (!['default', 'hardcover', 'spiral'].includes(style)) return;
        if (style === this.flipbookStyle) return;
        if (this.is2D && style !== 'default') return; // Hardcover/spiral require 3D mode

        // Exit viewers if active
        if (this.isVerticalViewer) this.exitVerticalViewer();
        if (this.isHorizontalViewer) this.exitHorizontalViewer();
        if (this.isFormViewer) this.exitFormViewer();
        if (this.isReadingMode) this.exitReadingMode();

        // Remove existing style elements
        this.removeStyleElements();

        // Update style
        const prevStyle = this.flipbookStyle;
        this.flipbookStyle = style;

        // Update body class
        document.body.classList.remove('flipbook-default', 'flipbook-hardcover', 'flipbook-spiral');
        document.body.classList.add(`flipbook-${style}`);

        // Sync page shadow casting (off in hardcover; see createPage comment)
        const allowCastShadow = style !== 'hardcover';
        for (const p of this.pages) {
            if (p && p.page) p.page.castShadow = allowCastShadow;
        }

        // Setup new style
        if (style === 'hardcover') {
            this.setupHardcoverStyle();
        } else if (style === 'spiral') {
            this.setupSpiralStyle();
        }

        // Spiral mode: keep whatever page view mode is currently active
        // (no forced single page switch)

        // Update hardcover visibility for single page mode
        this.updateHardcoverVisibility();
        this.updateSpiralPosition();

        this.updateViewMode();
        this.updatePageIndicator();

        // Update style option states in case popup is open
        this.updateStyleOptionStates();
    }

    updateHardcoverVisibility() {
        if (this.flipbookStyle !== 'hardcover' || !this.hardcoverGroup) return;

        if (this.forceSinglePage) {
            // Single page mode - show only one cover panel
            const isOddPage = this.currentSinglePage % 2 === 1;
            if (isOddPage) {
                // Odd page (right side of book) - show right cover
                if (this.frontCover) this.frontCover.visible = true;
                if (this.backCover) this.backCover.visible = false;
            } else {
                // Even page (left side of book) - show left cover
                if (this.frontCover) this.frontCover.visible = false;
                if (this.backCover) this.backCover.visible = true;
            }
            // Hide spine in single page mode
            if (this.spine) this.spine.visible = false;
        } else {
            // Double page mode - check spread position
            const flippedCount = this.pages.filter(p => p.flipped).length;
            const isFirstSpread = flippedCount === this.minFlipped;
            const isLastSpread = flippedCount === this.pages.length;

            const evenMode = this.isEvenPageMode;

            if (isFirstSpread && !evenMode) {
                // Odd mode first spread: cover page on right only
                if (this.frontCover) this.frontCover.visible = true;
                if (this.backCover) this.backCover.visible = false;
                if (this.spine) this.spine.visible = false;
            } else if (isLastSpread) {
                // Last spread: last page on left, nothing on right
                if (this.frontCover) this.frontCover.visible = false;
                if (this.backCover) this.backCover.visible = true;
                if (this.spine) this.spine.visible = false;
            } else {
                // Full open spread (middle spreads + even-mode first spread)
                if (this.frontCover) this.frontCover.visible = true;
                if (this.backCover) this.backCover.visible = true;
                if (this.spine) this.spine.visible = true;
            }
        }
    }

    removeStyleElements() {
        // Remove hardcover elements
        if (this.hardcoverGroup) {
            this.book.remove(this.hardcoverGroup);
            this.hardcoverGroup = null;
            this.frontCover = null;
            this.backCover = null;
            this.spine = null;
        }

        // Remove spiral elements
        if (this.spiralCoils) {
            this.book.remove(this.spiralCoils);
            this.spiralCoils = null;
        }
    }

    setupHardcoverStyle() {
        this.hardcoverGroup = new THREE.Group();

        // Border size visible around page edges
        const borderSize = 0.08;
        const coverDepth = 0.05;

        // Resolve cover color from admin override or active chrome polarity.
        // Spine and bevel are auto-derived shades so admin only picks one color.
        const coverHex = resolveCoverColor(this.isDarkTheme);
        const spineHex = _shadeHex(coverHex, -12);
        const bevelHex = _shadeHex(coverHex, +6);

        // Cover material. depthWrite:false so pages always render on top.
        const coverMaterial = new THREE.MeshStandardMaterial({
            color: coverHex,
            roughness: 0.75,
            metalness: 0.02,
            depthWrite: false
        });

        // Slightly darker edge/spine material
        const edgeMaterial = new THREE.MeshStandardMaterial({
            color: spineHex,
            roughness: 0.8,
            metalness: 0.02,
            depthWrite: false
        });

        // Left cover panel (behind left page)
        const leftW = CONFIG.pageWidth + borderSize;
        const leftH = CONFIG.pageHeight + borderSize * 2;
        const leftCoverGeo = new THREE.BoxGeometry(leftW, leftH, coverDepth);
        this.backCover = new THREE.Mesh(leftCoverGeo, coverMaterial);
        this.backCover.position.set(-leftW / 2, 0, -coverDepth / 2);

        // Right cover panel (behind right page)
        const rightW = CONFIG.pageWidth + borderSize;
        const rightH = CONFIG.pageHeight + borderSize * 2;
        const rightCoverGeo = new THREE.BoxGeometry(rightW, rightH, coverDepth);
        this.frontCover = new THREE.Mesh(rightCoverGeo, coverMaterial);
        this.frontCover.position.set(rightW / 2, 0, -coverDepth / 2);

        // Spine strip connecting left and right covers
        const spineWidth = 0.1;
        const spineGeo = new THREE.BoxGeometry(spineWidth, leftH, coverDepth + 0.02);
        this.spine = new THREE.Mesh(spineGeo, edgeMaterial);
        this.spine.position.set(0, 0, -coverDepth / 2);

        // Inner edge bevel on left cover (right edge)
        const bevelHeight = leftH - 0.04;
        const bevelGeo = new THREE.BoxGeometry(0.02, bevelHeight, coverDepth + 0.01);
        const bevelMaterial = new THREE.MeshStandardMaterial({
            color: bevelHex,
            roughness: 0.6,
            metalness: 0.05,
            depthWrite: false
        });
        const leftBevel = new THREE.Mesh(bevelGeo, bevelMaterial);
        leftBevel.position.set(-leftW + leftW / 2 + 0.01, 0, -coverDepth / 2);
        leftBevel.userData.isHardcoverBevel = true;
        this.backCover.add(leftBevel);

        const rightBevel = new THREE.Mesh(bevelGeo.clone(), bevelMaterial);
        rightBevel.position.set(rightW - rightW / 2 - 0.01, 0, -coverDepth / 2);
        rightBevel.userData.isHardcoverBevel = true;
        this.frontCover.add(rightBevel);

        // Render covers before pages so pages always draw on top
        this.backCover.renderOrder = -1;
        this.frontCover.renderOrder = -1;
        this.spine.renderOrder = -1;

        // Add to group
        this.hardcoverGroup.add(this.backCover);
        this.hardcoverGroup.add(this.frontCover);
        this.hardcoverGroup.add(this.spine);

        // Position behind the pages
        this.hardcoverGroup.position.z = -0.01;

        // Add to book
        this.book.add(this.hardcoverGroup);
    }

    // Recolor existing hardcover materials in place when theme polarity flips
    // or when admin's coverColor changes at runtime. No geometry rebuild.
    updateHardcoverColors() {
        if (!this.hardcoverGroup) return;
        const coverHex = resolveCoverColor(this.isDarkTheme);
        const spineHex = _shadeHex(coverHex, -12);
        const bevelHex = _shadeHex(coverHex, +6);
        if (this.backCover  && this.backCover.material)  this.backCover.material.color.set(coverHex);
        if (this.frontCover && this.frontCover.material) this.frontCover.material.color.set(coverHex);
        if (this.spine      && this.spine.material)      this.spine.material.color.set(spineHex);
        // Bevels are children of front/back cover meshes.
        [this.backCover, this.frontCover].forEach(parent => {
            if (!parent) return;
            parent.children.forEach(child => {
                if (child.userData && child.userData.isHardcoverBevel && child.material) {
                    child.material.color.set(bevelHex);
                }
            });
        });
    }

    setupSpiralStyle() {
        this.spiralCoils = new THREE.Group();

        // Coil spring spiral binding parameters
        const wireRadius = 0.015; // Wire thickness
        const coilDiameter = 0.12; // Overall coil diameter
        const coilRadius = coilDiameter / 2;
        const numCoils = Math.floor(CONFIG.pageHeight / 0.18);
        const coilPitch = CONFIG.pageHeight / numCoils; // Vertical spacing per turn
        const spineX = -0.04; // Position at spine edge

        // Premium chrome wire material
        const wireMaterial = new THREE.MeshStandardMaterial({
            color: 0xd4d4d8,
            roughness: 0.12,
            metalness: 0.92
        });

        // Create a continuous helix spiral using TubeGeometry
        // This looks like a real notebook coil spring
        class HelixCurve extends THREE.Curve {
            constructor(radius, pitch, turns) {
                super();
                this.radius = radius;
                this.pitch = pitch;
                this.turns = turns;
            }
            getPoint(t) {
                const angle = t * Math.PI * 2 * this.turns;
                const x = Math.cos(angle) * this.radius;
                const y = t * this.pitch * this.turns - (this.pitch * this.turns / 2);
                const z = Math.sin(angle) * this.radius;
                return new THREE.Vector3(x, y, z);
            }
        }

        // Create the main spiral coil
        const helixCurve = new HelixCurve(coilRadius, coilPitch, numCoils);
        const tubeSegments = numCoils * 32;
        const spiralGeo = new THREE.TubeGeometry(helixCurve, tubeSegments, wireRadius, 12, false);
        const spiralMesh = new THREE.Mesh(spiralGeo, wireMaterial);
        spiralMesh.rotation.x = 0; // Helix runs vertically along Y
        spiralMesh.position.set(spineX, 0, 0);
        this.spiralCoils.add(spiralMesh);

        // Add end caps (decorative wire ends)
        const capGeo = new THREE.SphereGeometry(wireRadius * 1.3, 8, 8);
        const topCap = new THREE.Mesh(capGeo, wireMaterial);
        const bottomCap = new THREE.Mesh(capGeo, wireMaterial);

        // Position caps at helix ends
        const topPoint = helixCurve.getPoint(1);
        const bottomPoint = helixCurve.getPoint(0);
        topCap.position.set(spineX + topPoint.x, topPoint.y, topPoint.z);
        bottomCap.position.set(spineX + bottomPoint.x, bottomPoint.y, bottomPoint.z);
        this.spiralCoils.add(topCap);
        this.spiralCoils.add(bottomCap);

        // Add a subtle spine shadow strip behind the spiral
        const stripMaterial = new THREE.MeshBasicMaterial({
            color: 0x000000,
            transparent: true,
            opacity: 0.15,
            side: THREE.DoubleSide
        });
        const stripGeo = new THREE.PlaneGeometry(0.06, CONFIG.pageHeight * 0.96);
        const shadowStrip = new THREE.Mesh(stripGeo, stripMaterial);
        shadowStrip.position.set(spineX, 0, -coilRadius - 0.01);
        shadowStrip.renderOrder = -1;
        this.spiralCoils.add(shadowStrip);

        this.book.add(this.spiralCoils);
    }

    rebuildPagesForSingleMode() {
        // Update book position for current single page
        this.book.position.x = this.getTargetBookPosition();
        this.targetBookX = this.book.position.x;

        // Flip pages to match current single page
        const targetFlipped = this.currentSinglePage % 2 === 1
            ? Math.floor((this.currentSinglePage - 1) / 2)
            : Math.floor(this.currentSinglePage / 2);

        this.pages.forEach((page, i) => {
            if (i < targetFlipped) {
                page.pivot.rotation.y = -Math.PI;
                page.flipped = true;
            } else {
                page.pivot.rotation.y = 0;
                page.flipped = false;
            }
        });

        this.updatePageZOrder();
    }

    updateStyleOptionStates() {
        const defaultOpt = document.getElementById('styleDefaultOption');
        const hardcoverOpt = document.getElementById('styleHardcoverOption');
        const spiralOpt = document.getElementById('styleSpiralOption');

        if (!defaultOpt && !hardcoverOpt && !spiralOpt) return;

        // Remove active from all first
        if (defaultOpt) defaultOpt.classList.remove('active');
        if (hardcoverOpt) hardcoverOpt.classList.remove('active');
        if (spiralOpt) spiralOpt.classList.remove('active');

        // Add active to the current style
        if (this.flipbookStyle === 'default' && defaultOpt) {
            defaultOpt.classList.add('active');
        } else if (this.flipbookStyle === 'hardcover' && hardcoverOpt) {
            hardcoverOpt.classList.add('active');
        } else if (this.flipbookStyle === 'spiral' && spiralOpt) {
            spiralOpt.classList.add('active');
        }
    }

    updateSpiralPosition() {
        if (this.flipbookStyle !== 'spiral' || !this.spiralCoils) return;

        if (this.forceSinglePage) {
            const isOddPage = this.currentSinglePage % 2 === 1;
            if (this.isRTL) {
                // RTL: spiral on right edge
                if (isOddPage) {
                    this.spiralCoils.position.x = CONFIG.pageWidth;
                } else {
                    this.spiralCoils.position.x = 0;
                }
            } else {
                // LTR: spiral on left edge
                if (isOddPage) {
                    this.spiralCoils.position.x = 0;
                } else {
                    this.spiralCoils.position.x = -CONFIG.pageWidth;
                }
            }
        } else {
            // Double page mode - spiral at center spine
            this.spiralCoils.position.x = 0;
        }
    }

    // Update sound option display in More menu
    updateSoundOptionDisplay() {
        const soundOption = document.getElementById('soundOption');
        const soundLabel = soundOption.querySelector('.sound-label');
        const soundOnIcon = soundOption.querySelector('.sound-on-icon');
        const soundOffIcon = soundOption.querySelector('.sound-off-icon');

        if (this.soundEnabled) {
            soundLabel.textContent = t('more.soundOn');
            soundOnIcon.style.display = 'block';
            soundOffIcon.style.display = 'none';
        } else {
            soundLabel.textContent = t('more.soundOff');
            soundOnIcon.style.display = 'none';
            soundOffIcon.style.display = 'block';
        }
    }

    // Read Aloud functionality
    // ==========================================
    // Read Aloud - Simplified Mini Player
    // ==========================================

    setupReadAloud() {
        const player = document.getElementById('audioPlayer');
        if (!player) return; // Audio player not in template (Lite)
        const readAloudBtn = document.getElementById('readAloudBtn');
        const playPauseBtn = document.getElementById('playPauseBtn');
        const prevBtn = document.getElementById('prevSentenceBtn');
        const nextBtn = document.getElementById('nextSentenceBtn');
        const speedBtn = document.getElementById('speedBtn');
        const settingsBtn = document.getElementById('settingsBtn');
        const closeBtn = document.getElementById('closePlayerBtn');
        const voiceSelect = document.getElementById('voiceSelect');
        const settingsPanel = document.getElementById('playerSettings');

        // Load voices
        this.loadVoices();
        if (this.speechSynthesis.onvoiceschanged !== undefined) {
            this.speechSynthesis.onvoiceschanged = () => this.loadVoices();
        }

        // Toolbar button - toggle play or show player
        readAloudBtn.addEventListener('click', () => {
            if (this.isReading) {
                this.togglePlayPause();
            } else {
                this.showPlayer();
                this.startReading();
            }
        });

        // Play/Pause button
        playPauseBtn.addEventListener('click', () => this.togglePlayPause());

        // Previous/Next sentence
        prevBtn.addEventListener('click', () => this.prevSentence());
        nextBtn.addEventListener('click', () => this.nextSentence());

        // Speed button
        speedBtn.addEventListener('click', () => this.cycleSpeed());

        // Settings toggle
        settingsBtn.addEventListener('click', () => {
            settingsPanel.classList.toggle('active');
        });

        // Voice select
        voiceSelect.addEventListener('change', (e) => {
            // For browser provider, update the speech voice
            if (this.ttsProvider === 'browser') {
                const selectedVoice = this.speechVoices.find(v => v.name === e.target.value);
                this.speechVoice = selectedVoice || null;
            }
            // Cloud providers use the select value directly in synthesis methods
        });

        // Close button
        closeBtn.addEventListener('click', () => this.stopReading());

        // Keyboard shortcuts
        document.addEventListener('keydown', (e) => {
            // Only handle if player is active or reading
            if (!player.classList.contains('active') && !this.isReading) return;

            // Don't handle if user is typing in an input
            if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;

            switch (e.key) {
                case ' ':
                    e.preventDefault();
                    this.togglePlayPause();
                    break;
                case 'ArrowLeft':
                    if (player.classList.contains('active')) {
                        e.preventDefault();
                        this.prevSentence();
                    }
                    break;
                case 'ArrowRight':
                    if (player.classList.contains('active')) {
                        e.preventDefault();
                        this.nextSentence();
                    }
                    break;
                case 'ArrowUp':
                    if (player.classList.contains('active')) {
                        e.preventDefault();
                        this.increaseSpeed();
                    }
                    break;
                case 'ArrowDown':
                    if (player.classList.contains('active')) {
                        e.preventDefault();
                        this.decreaseSpeed();
                    }
                    break;
                case 'Escape':
                    if (player.classList.contains('active')) {
                        this.stopReading();
                    }
                    break;
            }
        });

        // Progress bar click to seek
        document.getElementById('playerProgress').addEventListener('click', (e) => {
            if (this.currentSentences.length === 0) return;
            const rect = e.target.getBoundingClientRect();
            const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
            const targetIndex = Math.floor(percent * this.currentSentences.length);
            this.jumpToSentence(targetIndex);
        });

        // TTS Provider selection
        const providerSelect = document.getElementById('ttsProviderSelect');
        providerSelect.value = this.ttsProvider;
        providerSelect.addEventListener('change', (e) => {
            const val = e.target.value;
            if (val.startsWith('set:')) {
                const setKey = val.substring(4);
                const sets = TTS_CONFIG.sets;
                if (sets && sets[setKey]) {
                    this._activeSetKey = setKey;
                    this._activeSet = sets[setKey];
                    // Update the manifest to point to this set for playSentenceAudio
                    TTS_CONFIG.manifest = {
                        provider: sets[setKey].provider,
                        voice: sets[setKey].voice,
                        pages: sets[setKey].pages,
                        base_url: sets[setKey].base_url,
                    };
                    this.setTTSProvider(sets[setKey].provider);
                }
            } else {
                this._activeSetKey = null;
                this._activeSet = null;
                TTS_CONFIG.manifest = null;
                this.setTTSProvider(val);
            }
        });

        // Auto-flip toggle
        const autoFlipToggle = document.getElementById('autoFlipToggle');
        autoFlipToggle.checked = this.autoFlipEnabled;
        autoFlipToggle.addEventListener('change', (e) => {
            this.autoFlipEnabled = e.target.checked;
        });

        // Initialize TTS provider
        this.initTTSProvider();
    }

    // ==========================================
    // TTS Provider Management
    // ==========================================



    // ==========================================
    // TTS Synthesis Methods
    // ==========================================

    async synthesizeSpeech(text) {
        if (this.ttsProvider === 'browser') {
            return this.synthesizeBrowser(text);
        }
    }

    synthesizeBrowser(text) {
        // Use browser's built-in speech synthesis
        return new Promise((resolve, reject) => {
            this.speechUtterance = new SpeechSynthesisUtterance(text);
            this.speechUtterance.rate = this.speechRate;
            this.speechUtterance.pitch = this.speechPitch;

            if (this.speechVoice) {
                this.speechUtterance.voice = this.speechVoice;
            }

            this.speechUtterance.onend = () => resolve();
            this.speechUtterance.onerror = (e) => {
                if (e.error === 'canceled' || e.error === 'interrupted') {
                    resolve();
                } else {
                    reject(e);
                }
            };

            // Word boundary for highlighting
            this.speechUtterance.onboundary = (event) => {
                if (event.name === 'word') {
                    this.highlightWord(text, event.charIndex, event.charLength);
                }
            };

            this.speechSynthesis.speak(this.speechUtterance);
        });
    }


    // ==========================================
    // Browser TTS Voice Loading
    // ==========================================

    loadVoices() {
        const voiceSelect = document.getElementById('voiceSelect');
        if (!voiceSelect) return;
        this.speechVoices = this.speechSynthesis.getVoices();

        voiceSelect.innerHTML = '';

        if (this.speechVoices.length === 0) {
            voiceSelect.innerHTML = '<option value="">No voices available</option>';
            return;
        }

        // Score and sort voices - prefer high-quality English voices
        const scoredVoices = this.speechVoices.map(voice => {
            let score = 0;

            // Prefer English
            if (voice.lang.startsWith('en')) score += 100;

            // Prefer local/premium voices (often have better quality)
            if (voice.localService) score += 20;

            // Prefer voices with "enhanced", "premium", "natural" in name
            const name = voice.name.toLowerCase();
            if (name.includes('enhanced') || name.includes('premium') || name.includes('natural')) score += 50;
            if (name.includes('google') || name.includes('microsoft')) score += 30;

            // Slightly prefer female voices (often clearer for reading)
            if (name.includes('female') || name.includes('samantha') || name.includes('karen') || name.includes('moira')) score += 10;

            return { voice, score };
        }).sort((a, b) => b.score - a.score);

        scoredVoices.forEach(({ voice }) => {
            const option = document.createElement('option');
            option.value = voice.name;
            const shortLang = voice.lang.split('-')[0].toUpperCase();
            option.textContent = `${voice.name} (${shortLang})`;
            voiceSelect.appendChild(option);
        });

        // If the config names a preferred voice (substring match), use it.
        // Otherwise fall back to the highest-scored voice.
        const preferred = (TTS_CONFIG.defaultBrowserVoice || '').trim().toLowerCase();
        let chosen = null;
        if (preferred) {
            const match = scoredVoices.find(({ voice }) => voice.name.toLowerCase().includes(preferred));
            if (match) chosen = match.voice;
        }
        if (!chosen) chosen = scoredVoices[0]?.voice;
        if (chosen) {
            voiceSelect.value = chosen.name;
            this.speechVoice = chosen;
        }
    }

    getCurrentPageText() {
        let text = '';

        if (this.forceSinglePage) {
            const pageIndex = this.currentSinglePage - 1;
            if (this.pageTextContent[pageIndex]) {
                text = this.pageTextContent[pageIndex];
            }
        } else {
            const flippedCount = this.pages.filter(p => p.flipped).length;
            const isAtStart = flippedCount === this.minFlipped;
            const isAtEnd = flippedCount === this.pages.length;

            if (isAtStart) {
                if (this.pageTextContent[0]) text = this.pageTextContent[0];
                if (this.isEvenPageMode && this.pageTextContent[1]) text += ' ' + this.pageTextContent[1];
            } else if (isAtEnd) {
                const lastPageIndex = this.numPages - 1;
                if (this.pageTextContent[lastPageIndex]) text = this.pageTextContent[lastPageIndex];
            } else {
                const leftPage = this.isEvenPageMode ? flippedCount * 2 - 2 : flippedCount * 2 - 1;
                const rightPage = leftPage + 1;
                if (this.pageTextContent[leftPage]) text += this.pageTextContent[leftPage] + ' ';
                if (rightPage < this.numPages && this.pageTextContent[rightPage]) {
                    text += this.pageTextContent[rightPage];
                }
            }
        }

        return this.cleanText(text.trim());
    }

    cleanText(text) {
        // Smart text cleaning for better reading
        return text
            // Remove multiple spaces
            .replace(/\s+/g, ' ')
            // Remove page numbers (standalone numbers)
            .replace(/^\d+\s*$/gm, '')
            // Remove common headers/footers patterns
            .replace(/^(page|chapter|section)\s*\d+\s*$/gim, '')
            // Fix common OCR issues
            .replace(/\s+([.,!?;:])/g, '$1')
            .trim();
    }

    splitIntoSentences(text) {
        // Better sentence splitting with abbreviation handling
        const abbrevs = ['Mr', 'Mrs', 'Ms', 'Dr', 'Prof', 'Sr', 'Jr', 'vs', 'etc', 'Inc', 'Ltd', 'Co', 'Corp'];
        let processed = text;

        // Protect abbreviations
        abbrevs.forEach((abbr, i) => {
            const regex = new RegExp(`\\b${abbr}\\.`, 'g');
            processed = processed.replace(regex, `${abbr}<<<${i}>>>`);
        });

        // Split on sentence endings
        const sentences = processed
            .replace(/([.!?])\s+/g, '$1|SPLIT|')
            .split('|SPLIT|')
            .map(s => {
                // Restore abbreviations
                let result = s;
                abbrevs.forEach((abbr, i) => {
                    result = result.replace(new RegExp(`${abbr}<<<${i}>>>`, 'g'), `${abbr}.`);
                });
                return result.trim();
            })
            .filter(s => s.length > 2); // Filter out very short "sentences"

        return sentences;
    }

    showPlayer() {
        const player = document.getElementById('audioPlayer');
        player.classList.add('active');
        this.updatePlayerUI();
    }

    hidePlayer() {
        const player = document.getElementById('audioPlayer');
        player.classList.remove('active');
        document.getElementById('playerSettings').classList.remove('active');
    }

    startReading() {
        this.cancelCurrentSpeech();

        const text = this.getCurrentPageText();

        if (!text) {
            this.updatePlayerSentence('No text found on this page');
            return;
        }

        this.currentSentences = this.splitIntoSentences(text);
        this.currentSentenceIndex = 0;

        if (this.currentSentences.length === 0) {
            this.updatePlayerSentence('No readable text found');
            return;
        }

        this.isReading = true;
        this.isPaused = false;
        this.showPlayer();
        this.readCurrentSentence();

        // Track read aloud start
        const currentPage = this.forceSinglePage ? this.currentSinglePage :
            (this.pages.filter(p => p.flipped).length * 2 || 1);
        analytics.trackReadAloud('start', currentPage);
    }

    async readCurrentSentence() {
        // When cloud TTS sentence files are actively playing, only
        // _playNextSentenceFile should trigger onPageReadingComplete.
        // Stray calls (e.g. from stale browser speech events) must not
        // race with the sentence-file playback chain.
        if (this._sentenceAudioFiles) return;

        if (!this.isReading || this.currentSentenceIndex >= this.currentSentences.length) {
            this.onPageReadingComplete();
            return;
        }

        const sentence = this.currentSentences[this.currentSentenceIndex];
        this.updatePlayerSentence(sentence);
        this.updatePlayerProgress();
        this.updatePlayerUI();


        // Browser speech synthesis — sentence by sentence (free, no cost concern)
        this.speechUtterance = new SpeechSynthesisUtterance(sentence);
        this.speechUtterance.rate = this.speechRate;
        this.speechUtterance.pitch = this.speechPitch;

        if (this.speechVoice) {
            this.speechUtterance.voice = this.speechVoice;
        }

        // Word highlighting
        this.speechUtterance.onboundary = (event) => {
            if (event.name === 'word') {
                this.highlightWord(sentence, event.charIndex, event.charLength);
            }
        };

        this.speechUtterance.onend = () => {
            if (!this.isReading) return;
            this.currentSentenceIndex++;
            setTimeout(() => this.readCurrentSentence(), 100);
        };

        this.speechUtterance.onerror = (event) => {
            if (event.error === 'canceled' || event.error === 'interrupted') return;
            this.currentSentenceIndex++;
            setTimeout(() => this.readCurrentSentence(), 100);
        };

        this.speechSynthesis.speak(this.speechUtterance);
    }

    onPageReadingComplete() {
        if (!this.autoFlipEnabled || !this.isReading) {
            this.finishReading();
            return;
        }

        if (this._isAtLastPage()) {
            this.finishReading();
            return;
        }

        this.updatePlayerSentence('Turning page...');

        setTimeout(() => {
            if (!this.isReading) return;
            this.flipPage('next');

            // Wait for the flip animation to finish before reading the new page.
            // pages[i].flipped only updates at end-of-animation, so a fixed setTimeout
            // races with flipDuration (and any user-configured longer durations).
            const waitForFlipEnd = (attempts = 0) => {
                if (!this.isReading) return;
                const stillAnimating = this.isAnimating === true;
                const maxAttempts = 60; // ~6s safety cap (60 × 100ms)
                if (stillAnimating && attempts < maxAttempts) {
                    setTimeout(() => waitForFlipEnd(attempts + 1), 100);
                    return;
                }
                // Small settle delay so any post-flip state updates land first.
                setTimeout(() => {
                    if (!this.isReading) return;
                    this._readCurrentPageAfterFlip();
                }, 80);
            };
            waitForFlipEnd();
        }, 500);
    }

    _readCurrentPageAfterFlip() {
        const text = this.getCurrentPageText();
        if (text) {
            this.currentSentences = this.splitIntoSentences(text);
            this.currentSentenceIndex = 0;
            this.updatePlayerTime();
            this.readCurrentSentence();
        } else {
            this.finishReading();
        }
    }

    /**
     * True when the currently-visible page (or spread) is the last one in the
     * document — used by Read Aloud auto-flip to know when to stop.
     */
    _isAtLastPage() {
        if (this.isVerticalViewer) return (this._verticalCurrentPage || 1) >= this.numPages;
        if (this.isHorizontalViewer) return (this._horizontalCurrentPage || 1) >= this.numPages;
        if (this.isFormViewer) return (this._formCurrentPage || 1) >= this.numPages;
        if (this.forceSinglePage) return (this.currentSinglePage || 1) >= this.numPages;
        if (!this.pages || !this.pages.length) return true;
        const flippedCount = this.pages.filter(p => p.flipped).length;
        if (flippedCount >= this.pages.length) return true;
        // Last spread: in odd mode the last sheet still has pages numPages-1 and numPages
        // visible together, so don't auto-flip past it.
        if (!this.isEvenPageMode && flippedCount === this.pages.length - 1 && this.numPages % 2 === 1) {
            return true;
        }
        return false;
    }

    finishReading() {
        this.isReading = false;
        this.isPaused = false;
        this.updatePlayerUI();
        this.updatePlayerSentence('Finished reading');

        setTimeout(() => {
            if (!this.isReading) {
                this.hidePlayer();
            }
        }, 3000);
    }

    togglePlayPause() {
        if (!this.isReading) {
            this.startReading();
        } else if (this.isPaused) {
            this.resumeReading();
        } else {
            this.pauseReading();
        }
    }

    pauseReading() {
        if (this.isReading && !this.isPaused) {
            // Handle cloud TTS audio
            if (this.ttsAudioElement) {
                this.ttsAudioElement.pause();
            }
            // Handle browser speech synthesis
            this.speechSynthesis.pause();
            this.isPaused = true;
            this.updatePlayerUI();
        }
    }

    resumeReading() {
        if (this.isReading && this.isPaused) {
            this.isPaused = false;
            this.updatePlayerUI();

            // Handle cloud sentence-level audio
            if (this._sentenceAudioFiles) {
                if (this.ttsAudioElement) {
                    this.ttsAudioElement.play();
                } else {
                    // Between sentences — start the next file
                    this._playNextSentenceFile();
                }
                return;
            }

            // Handle cloud single audio
            if (this.ttsAudioElement) {
                this.ttsAudioElement.play();
            }
            // Handle browser speech synthesis
            this.speechSynthesis.resume();
        }
    }

    stopReading() {
        // Track read aloud stop
        const currentPage = this.forceSinglePage ? this.currentSinglePage :
            (this.pages.filter(p => p.flipped).length * 2 || 1);
        analytics.trackReadAloud('stop', currentPage);

        // Clear sentence-level file state
        this._sentenceAudioFiles = null;

        // Stop cloud TTS audio
        if (this.ttsAudioElement) {
            this.ttsAudioElement.pause();
            this.ttsAudioElement.currentTime = 0;
            this.ttsAudioElement = null;
        }
        // Stop browser speech synthesis
        this.speechSynthesis.cancel();
        this.isReading = false;
        this.isPaused = false;
        this.currentSentences = [];
        this.currentSentenceIndex = 0;
        this.hidePlayer();
    }

    cancelCurrentSpeech(destroyAudio = true) {
        // Stop cloud TTS audio
        if (this.ttsAudioElement) {
            this.ttsAudioElement.pause();
            if (destroyAudio) {
                this.ttsAudioElement.currentTime = 0;
                this.ttsAudioElement = null;
            }
        }
        // Clear cloud sentence-file playback chain so readCurrentSentence
        // isn't blocked by the _sentenceAudioFiles guard.
        if (destroyAudio) {
            this._sentenceAudioFiles = null;
        }
        // Clear stale speech utterance handlers BEFORE canceling.
        // Chrome fires onend asynchronously after cancel(), which can
        // trigger a premature onPageReadingComplete if isReading is
        // already true from a new startReading() call.
        if (this.speechUtterance) {
            this.speechUtterance.onend = null;
            this.speechUtterance.onerror = null;
            this.speechUtterance.onboundary = null;
            this.speechUtterance = null;
        }
        // Stop browser speech synthesis
        this.speechSynthesis.cancel();
    }

    /**
     * Seek cloud audio to a specific sentence by stopping current file and playing the target.
     * Returns true if handled (sentence-level files active), false otherwise.
     */
    _seekCloudAudio(sentenceIndex) {
        if (this.ttsProvider === 'browser' || !this._sentenceAudioFiles) {
            return false;
        }
        if (sentenceIndex < 0 || sentenceIndex >= this._sentenceAudioFiles.length) {
            return false;
        }

        // Stop current audio
        if (this.ttsAudioElement) {
            this.ttsAudioElement.pause();
            this.ttsAudioElement = null;
        }

        this.currentSentenceIndex = sentenceIndex;
        this.updatePlayerSentence(this.currentSentences[sentenceIndex]);
        this.updatePlayerProgress();

        // Play from this sentence
        if (this.isReading && !this.isPaused) {
            this._playNextSentenceFile();
        } else if (this.isPaused) {
            // Resume from new position
            this.isPaused = false;
            this.isReading = true;
            this.updatePlayerUI();
            this._playNextSentenceFile();
        }
        return true;
    }

    prevSentence() {
        if (this.currentSentences.length === 0) return;
        const newIndex = Math.max(0, this.currentSentenceIndex - 1);
        if (this._seekCloudAudio(newIndex)) return;

        this.cancelCurrentSpeech();
        this.currentSentenceIndex = newIndex;
        if (this.isReading && !this.isPaused) {
            this.readCurrentSentence();
        } else {
            this.updatePlayerSentence(this.currentSentences[this.currentSentenceIndex]);
            this.updatePlayerProgress();
        }
    }

    nextSentence() {
        if (this.currentSentences.length === 0) return;
        const newIndex = Math.min(this.currentSentences.length - 1, this.currentSentenceIndex + 1);
        if (this._seekCloudAudio(newIndex)) return;

        this.cancelCurrentSpeech();
        this.currentSentenceIndex = newIndex;
        if (this.isReading && !this.isPaused) {
            this.readCurrentSentence();
        } else {
            this.updatePlayerSentence(this.currentSentences[this.currentSentenceIndex]);
            this.updatePlayerProgress();
        }
    }

    jumpToSentence(index) {
        if (this.currentSentences.length === 0) return;
        const newIndex = Math.max(0, Math.min(this.currentSentences.length - 1, index));
        if (this._seekCloudAudio(newIndex)) return;

        this.cancelCurrentSpeech();
        this.currentSentenceIndex = newIndex;
        if (this.isReading && !this.isPaused) {
            this.readCurrentSentence();
        } else {
            this.updatePlayerSentence(this.currentSentences[this.currentSentenceIndex]);
            this.updatePlayerProgress();
        }
    }

    cycleSpeed() {
        this.currentSpeedIndex = (this.currentSpeedIndex + 1) % this.speedLevels.length;
        this.speechRate = this.speedLevels[this.currentSpeedIndex];
        this.updateSpeedButton();

        // If currently reading, restart current sentence with new speed
        if (this.isReading && !this.isPaused) {
            this.cancelCurrentSpeech();
            this.readCurrentSentence();
        }
    }

    increaseSpeed() {
        if (this.currentSpeedIndex < this.speedLevels.length - 1) {
            this.currentSpeedIndex++;
            this.speechRate = this.speedLevels[this.currentSpeedIndex];
            this.updateSpeedButton();
            if (this.isReading && !this.isPaused) {
                this.cancelCurrentSpeech();
                this.readCurrentSentence();
            }
        }
    }

    decreaseSpeed() {
        if (this.currentSpeedIndex > 0) {
            this.currentSpeedIndex--;
            this.speechRate = this.speedLevels[this.currentSpeedIndex];
            this.updateSpeedButton();
            if (this.isReading && !this.isPaused) {
                this.cancelCurrentSpeech();
                this.readCurrentSentence();
            }
        }
    }

    updateSpeedButton() {
        const speedBtn = document.getElementById('speedBtn');
        speedBtn.textContent = `${this.speechRate}x`;
    }

    updatePlayerUI() {
        const playIcon = document.querySelector('#playPauseBtn .play-icon');
        const pauseIcon = document.querySelector('#playPauseBtn .pause-icon');
        const readAloudBtn = document.getElementById('readAloudBtn');

        if (this.isReading && !this.isPaused) {
            playIcon.style.display = 'none';
            pauseIcon.style.display = 'block';
            readAloudBtn.classList.add('active');
        } else {
            playIcon.style.display = 'block';
            pauseIcon.style.display = 'none';
            if (!this.isReading) readAloudBtn.classList.remove('active');
        }

        this.updatePlayerTime();
    }

    updatePlayerSentence(text) {
        const sentenceEl = document.getElementById('playerSentence');
        sentenceEl.textContent = text;
    }

    highlightWord(sentence, charIndex, charLength) {
        if (charIndex === undefined || charLength === undefined) {
            this.updatePlayerSentence(sentence);
            return;
        }

        const before = sentence.substring(0, charIndex);
        const word = sentence.substring(charIndex, charIndex + charLength);
        const after = sentence.substring(charIndex + charLength);

        const sentenceEl = document.getElementById('playerSentence');
        sentenceEl.innerHTML = `${this.escapeHtml(before)}<span class="highlight">${this.escapeHtml(word)}</span>${this.escapeHtml(after)}`;
    }

    escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }

    updatePlayerProgress() {
        const progressFill = document.getElementById('playerProgressFill');
        const progress = this.currentSentences.length > 0
            ? ((this.currentSentenceIndex + 1) / this.currentSentences.length) * 100
            : 0;
        progressFill.style.width = `${progress}%`;
    }

    updatePlayerTime() {
        const timeEl = document.getElementById('playerTime');
        const currentPage = this.getCurrentPageNumber();
        timeEl.textContent = `Page ${currentPage} / ${this.numPages}`;
    }

    // PDF outline resolution
    async resolveOutline(items, pdfDoc, pageOffset = 0) {
        const resolved = [];
        for (const item of items) {
            let pageNum = null;
            if (item.dest) {
                try {
                    const dest = typeof item.dest === 'string'
                        ? await pdfDoc.getDestination(item.dest)
                        : item.dest;
                    if (dest && dest[0]) {
                        const pageIndex = await pdfDoc.getPageIndex(dest[0]);
                        pageNum = pageIndex + 1 + pageOffset;
                    }
                } catch (e) { /* skip unresolvable destinations */ }
            }
            const entry = { title: item.title, page: pageNum, children: [] };
            if (item.items && item.items.length > 0) {
                entry.children = await this.resolveOutline(item.items, pdfDoc, pageOffset);
            }
            resolved.push(entry);
        }
        return resolved;
    }

    // Bookmark sidebar (TOC/outline in Free, user bookmarks in Pro)
    openBookmarksSidebar() {
        const sidebar = document.getElementById('bookmarksSidebar');
        if (!sidebar) return;
        sidebar.classList.add('active');
        this.renderBookmarksList();
    }

    closeBookmarksSidebar() {
        const sidebar = document.getElementById('bookmarksSidebar');
        if (!sidebar) return;
        sidebar.classList.remove('active');
    }

    renderBookmarksList() {
        const empty = document.getElementById('bookmarksEmpty');
        const outlineSection = document.getElementById('pdfOutlineSection');
        const outlineList = document.getElementById('pdfOutlineList');

        if (!outlineSection || !outlineList) return;

        const hasOutline = this.pdfOutline && this.pdfOutline.length > 0;
        const hasBookmarks = this.bookmarks && this.bookmarks.length > 0;

        // Show/hide empty state
        if (empty) {
            empty.style.display = (!hasOutline && !hasBookmarks) ? 'flex' : 'none';
        }

        // Render PDF outline (available in Free)
        outlineList.innerHTML = '';
        if (hasOutline) {
            outlineSection.style.display = '';
            const renderOutlineItems = (items, container) => {
                items.forEach(entry => {
                    const item = document.createElement('div');
                    item.className = 'outline-item';
                    if (entry.page) {
                        item.setAttribute('tabindex', '0');
                        item.setAttribute('role', 'button');
                    }
                    item.innerHTML = `
                        <div class="outline-item-icon">
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                                <path d="M4 6h16M4 12h16M4 18h10"/>
                            </svg>
                        </div>
                        <div class="outline-item-info">
                            <div class="outline-item-title">${entry.title}</div>
                        </div>
                        ${entry.page ? `<span class="outline-item-page">${entry.page}</span>` : ''}
                    `;
                    if (entry.page) {
                        const goToOutline = () => {
                            this.goToPage(entry.page);
                            this.closeBookmarksSidebar();
                        };
                        item.addEventListener('click', goToOutline);
                        item.addEventListener('keydown', (e) => {
                            if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goToOutline(); }
                        });
                    }
                    container.appendChild(item);

                    if (entry.children && entry.children.length > 0) {
                        const childContainer = document.createElement('div');
                        childContainer.className = 'outline-children';
                        renderOutlineItems(entry.children, childContainer);
                        container.appendChild(childContainer);
                    }
                });
            };
            renderOutlineItems(this.pdfOutline, outlineList);
        } else {
            outlineSection.style.display = 'none';
        }

        // Render user bookmarks (Pro only — elements may not exist in Lite)
    }

        addCurrentPageBookmark() {}

    _findViewerPageElement(prefix, pageNum) {
        // Try exact match first
        let el = document.getElementById(`${prefix}-page-${pageNum}`);
        if (el) return el;
        // In spread mode, find the wrapper containing this page via data-pageNum
        const container = document.getElementById(
            prefix === 'vertical' ? 'verticalViewerContainer' : 'horizontalViewerInner'
        );
        if (!container) return null;
        // Find the spread that contains this page (closest pageNum <= target)
        const pages = container.querySelectorAll(`[id^="${prefix}-page-"]`);
        let best = null;
        for (const p of pages) {
            const pn = parseInt(p.dataset.pageNum, 10);
            if (pn <= pageNum) {
                if (!best || pn > parseInt(best.dataset.pageNum, 10)) best = p;
            }
        }
        return best;
    }

    // Notes functionality
        buildNoteHotspots() {}
        repositionNoteHotspots() {}

    // ==========================================
    // Config Hotspots (creator-defined)
    // ==========================================

        buildConfigHotspots() {}
        repositionConfigHotspots() {}
        hideConfigPopup() {}

    // Notification helper
    showNotification(message) {
        // Create or reuse notification element
        let notification = document.getElementById('flipbookNotification');
        if (!notification) {
            notification = document.createElement('div');
            notification.id = 'flipbookNotification';
            notification.style.cssText = `
                position: fixed;
                bottom: 100px;
                left: 50%;
                transform: translateX(-50%) translateY(20px);
                background: rgba(0, 0, 0, 0.85);
                color: white;
                padding: 12px 24px;
                border-radius: 8px;
                font-size: 14px;
                z-index: 1000;
                opacity: 0;
                transition: all 0.3s ease;
                pointer-events: none;
            `;
            document.body.appendChild(notification);
        }

        notification.textContent = message;
        notification.style.opacity = '1';
        notification.style.transform = 'translateX(-50%) translateY(0)';

        // Announce to screen readers
        if (typeof a11y !== 'undefined' && a11y) {
            a11y.announce(message);
        }

        setTimeout(() => {
            notification.style.opacity = '0';
            notification.style.transform = 'translateX(-50%) translateY(20px)';
        }, 2000);
    }

    // Search functionality
    performSearch(query) {
        this.searchResults = [];
        this.currentSearchIndex = -1;

        // Search not available for image-based flipbooks
        if (this.isImageBased) {
            document.getElementById('searchStatus').textContent = t('search.notAvailable');
            return;
        }

        const lowerQuery = query.toLowerCase();

        // Search through all pages
        this.pageTextContent.forEach((text, index) => {
            const lowerText = text.toLowerCase();
            let pos = 0;

            while ((pos = lowerText.indexOf(lowerQuery, pos)) !== -1) {
                // Get context around the match
                const start = Math.max(0, pos - 40);
                const end = Math.min(text.length, pos + query.length + 40);
                let context = text.substring(start, end);

                if (start > 0) context = '...' + context;
                if (end < text.length) context = context + '...';

                this.searchResults.push({
                    pageNum: index + 1,
                    position: pos,
                    context: context,
                    query: query
                });

                pos += query.length;
            }
        });

        this.displaySearchResults();

        // Announce search results to screen readers
        if (typeof a11y !== 'undefined' && a11y) {
            const count = this.searchResults.length;
            const msg = count === 0
                ? t('search.noResults')
                : (count === 1 ? t('search.result', { query }) : t('search.results', { count, query }));
            a11y.announce(msg);
        }

        // Track search in analytics
        analytics.trackSearch(query, this.searchResults.length);

        if (this._tncfb) this._tncfb.emit('search:result', { query, count: this.searchResults.length });
    }

    displaySearchResults() {
        const resultsContainer = document.getElementById('searchResults');
        const statusEl = document.getElementById('searchStatus');

        resultsContainer.innerHTML = '';

        if (this.searchResults.length === 0) {
            statusEl.textContent = t('search.noResults');
            return;
        }

        const count = this.searchResults.length;
        statusEl.textContent = count === 1
            ? t('search.result', { query: this.searchResults[0]?.query || '' })
            : t('search.results', { count, query: this.searchResults[0]?.query || '' });

        // Group results by page
        const groupedResults = {};
        this.searchResults.forEach((result, index) => {
            if (!groupedResults[result.pageNum]) {
                groupedResults[result.pageNum] = [];
            }
            groupedResults[result.pageNum].push({ ...result, index });
        });

        // Display grouped results by page
        Object.keys(groupedResults).sort((a, b) => a - b).forEach(pageNum => {
            const pageResults = groupedResults[pageNum];

            const pageGroup = document.createElement('div');
            pageGroup.className = 'search-result-group';

            const pageHeader = document.createElement('div');
            pageHeader.className = 'search-result-header';
            pageHeader.setAttribute('tabindex', '0');
            pageHeader.setAttribute('role', 'button');
            pageHeader.innerHTML = `
                <span class="search-result-page-label">Page ${pageNum}</span>
                <span class="search-result-count">${pageResults.length} Result${pageResults.length > 1 ? 's' : ''}</span>
            `;
            const goToResult = () => this.goToSearchResult(pageResults[0].index);
            pageHeader.addEventListener('click', goToResult);
            pageHeader.addEventListener('keydown', (e) => {
                if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goToResult(); }
            });

            pageGroup.appendChild(pageHeader);
            resultsContainer.appendChild(pageGroup);
        });
    }

    goToSearchResult(index) {
        if (index < 0 || index >= this.searchResults.length) return;

        this.currentSearchIndex = index;
        const result = this.searchResults[index];

        // Navigate to the page (goToPage now expects page number)
        this.goToPage(result.pageNum);

        // Highlight current result header in list
        document.querySelectorAll('.search-result-header').forEach((header) => {
            header.classList.remove('active');
        });
        const headers = document.querySelectorAll('.search-result-header');
        headers.forEach((header, i) => {
            const pageLabel = header.querySelector('.search-result-page-label');
            if (pageLabel && pageLabel.textContent === `Page ${result.pageNum}`) {
                header.classList.add('active');
            }
        });
    }

    clearSearchResults() {
        this.searchResults = [];
        this.currentSearchIndex = -1;
        document.getElementById('searchResults').innerHTML = '';
        document.getElementById('searchStatus').textContent = '';
        document.getElementById('searchInput').value = '';
    }

    closeSearch() {
        document.getElementById('searchSidebar').classList.remove('active');
    }

    // Download the PDF file (or images for image-based flipbooks)
    downloadPDF() {
        // Bubble a `tncfb:download` event for analytics beacons. Fires on
        // intent (even if a downstream gate blocks the actual download) so the
        // signal matches the WP Pro analytics-addon semantics. The event API
        // lives on the TNCFlipbook wrapper (this._tncfb), not on the internal
        // Flipbook engine — calling this.emit() here throws and aborts the
        // download (and previously the share modal too).
        this._tncfb?.emit('download', { page: this.getCurrentPageNumber?.() });



        // For image-based flipbooks, show notification that download isn't
        // available — unless the host also exposed CONFIG.pdfSource (cloud
        // does this for PDF-backed flipbooks that ship pre-rendered JPEGs
        // for fast paged rendering). In that case fall through and download
        // the original PDF.
        if (this.isImageBased && !CONFIG.pdfSource) {
            this.showNotification(t('notify.downloadNotAvailable'));
            return;
        }

        // For multi-PDF sources, download the first one (or show notification)
        if (CONFIG.pdfSources && CONFIG.pdfSources.length > 1) {
            this.showNotification(t('notify.downloadingPdf'));
        }

        // Create a link element and trigger download
        const link = document.createElement('a');
        const pdfSrc = CONFIG.pdfSources ? CONFIG.pdfSources[0].src : CONFIG.pdfSource;
        link.href = pdfSrc;
        link.download = CONFIG.downloadFilename || 'document.pdf';

        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);

        analytics.trackDownload();
    }


    // Print the PDF - opens print dialog for page selection
    printPDF() {
        this.openPrintDialog();
    }

    // Open print options dialog (falls back to simple print if dialog HTML not present)
    openPrintDialog() {
        const modal = document.getElementById('printModal');
        if (!modal) {
            // No print dialog (Lite) — print all pages using canvas-based method
            const allPages = Array.from({ length: this.numPages }, (_, i) => i + 1);
            this.printSelectedPages(allPages);
            return;
        }
        const allPagesDesc = document.getElementById('printAllPagesDesc');
        const pagesInput = document.getElementById('printPagesInput');
        const rangeInput = document.getElementById('printPagesRange');
        const radioAll = document.querySelector('input[name="printRange"][value="all"]');
        const radioCustom = document.querySelector('input[name="printRange"][value="custom"]');

        // Update description with total pages
        allPagesDesc.textContent = `Print all ${this.numPages} pages`;

        // Reset to defaults
        radioAll.checked = true;
        pagesInput.style.display = 'none';
        rangeInput.value = '';

        // Handle radio changes
        const handleRadioChange = () => {
            pagesInput.style.display = radioCustom.checked ? 'block' : 'none';
            if (radioCustom.checked) {
                rangeInput.focus();
            }
        };

        radioAll.onchange = handleRadioChange;
        radioCustom.onchange = handleRadioChange;

        modal.classList.add('active');
        if (typeof a11y !== 'undefined' && a11y) a11y.trapFocus(modal);
    }

    closePrintDialog() {
        const modal = document.getElementById('printModal');
        if (!modal) return;
        modal.classList.remove('active');
        if (typeof a11y !== 'undefined' && a11y) a11y.releaseFocus(modal);
    }

    // Parse page range string like "1-5, 8, 10-12" into array of page numbers
    parsePageRange(rangeStr) {
        const pages = new Set();
        const parts = rangeStr.split(',');

        for (const part of parts) {
            const trimmed = part.trim();
            if (!trimmed) continue;

            if (trimmed.includes('-')) {
                const [start, end] = trimmed.split('-').map(s => parseInt(s.trim(), 10));
                if (!isNaN(start) && !isNaN(end)) {
                    const min = Math.max(1, Math.min(start, end));
                    const max = Math.min(this.numPages, Math.max(start, end));
                    for (let i = min; i <= max; i++) {
                        pages.add(i);
                    }
                }
            } else {
                const num = parseInt(trimmed, 10);
                if (!isNaN(num) && num >= 1 && num <= this.numPages) {
                    pages.add(num);
                }
            }
        }

        return Array.from(pages).sort((a, b) => a - b);
    }

    // Execute print with specified pages (or all if null)
    executePrint(pageNumbers = null) {
        this.closePrintDialog();
        analytics.trackPrint();

        // For image-based flipbooks, always use image-based printing
        if (this.isImageBased) {
            const pages = pageNumbers || Array.from({ length: this.numPages }, (_, i) => i + 1);
            this.printSelectedPages(pages);
            return;
        }

        // If hideSourcePaths is enabled, use image-based printing to avoid exposing PDF URL
        if (CONFIG.contentProtection?.enabled && CONFIG.contentProtection?.hideSourcePaths) {
            const pages = pageNumbers || Array.from({ length: this.numPages }, (_, i) => i + 1);
            this.printSelectedPages(pages);
            return;
        }

        // If form data or custom annotations have been added, use printFilledPDF
        const hasFormData = this.annotationStorage && this.annotationStorage.size > 0;
        const hasAnnotations = this.formAnnotations && this.formAnnotations.length > 0;
        if (hasFormData || hasAnnotations) {
            this.printFilledPDF(pageNumbers);
            return;
        }

        // For default print without annotations, use image-based print for page selection
        if (pageNumbers && pageNumbers.length > 0 && pageNumbers.length < this.numPages) {
            this.printSelectedPages(pageNumbers);
            return;
        }

        // Print all pages using iframe method (fastest for full PDF)
        const printFrame = document.createElement('iframe');
        printFrame.style.position = 'fixed';
        printFrame.style.right = '0';
        printFrame.style.bottom = '0';
        printFrame.style.width = '0';
        printFrame.style.height = '0';
        printFrame.style.border = 'none';
        printFrame.src = CONFIG.pdfSource;

        document.body.appendChild(printFrame);

        printFrame.onload = () => {
            setTimeout(() => {
                printFrame.contentWindow.focus();
                printFrame.contentWindow.print();

                // Remove iframe after print dialog closes
                setTimeout(() => {
                    document.body.removeChild(printFrame);
                }, 1000);
            }, 500);
        };
    }

    // Print selected pages using cached images (via hidden iframe — no new tab)
    printSelectedPages(pageNumbers) {
        this.showNotification(t('notify.printPreparing'));

        // Use a hidden iframe instead of window.open to keep print on the same tab
        let printFrame = document.getElementById('tncfb-print-frame');
        if (printFrame) printFrame.remove();
        printFrame = document.createElement('iframe');
        printFrame.id = 'tncfb-print-frame';
        printFrame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:none;opacity:0;pointer-events:none;';
        document.body.appendChild(printFrame);
        const printWindow = printFrame.contentWindow;
        if (!printWindow) {
            this.showNotification(t('notify.printFailed') || 'Print failed.');
            return;
        }

        const shouldAddWatermark = false;
        let watermarkText = '';
        let watermarkStyles = '';

        printWindow.document.write(`
            <!DOCTYPE html>
            <html>
            <head>
                <title>Print PDF</title>
                <style>
                    * { margin: 0; padding: 0; box-sizing: border-box; }
                    body { background: white; }
                    .page {
                        page-break-after: always;
                        position: relative;
                        width: 100%;
                        max-width: 100%;
                    }
                    .page:last-child { page-break-after: auto; }
                    .page img {
                        display: block;
                        width: 100%;
                        height: auto;
                    }
                    @media print {
                        .page { page-break-after: always; }
                        .page:last-child { page-break-after: auto; }
                    }
                    ${watermarkStyles}
                </style>
            </head>
            <body>
                <div id="pages"></div>
            </body>
            </html>
        `);
        printWindow.document.close();

        const pagesContainer = printWindow.document.getElementById('pages');


        for (const pageNum of pageNumbers) {
            const i = pageNum - 1;
            let printPageUrl = this.getPageDataURL(i);


            if (printPageUrl) {
                const pageDiv = printWindow.document.createElement('div');
                pageDiv.className = 'page';

                const img = printWindow.document.createElement('img');
                img.src = printPageUrl;
                pageDiv.appendChild(img);

                // Add watermark overlay if enabled
                if (shouldAddWatermark && watermarkText) {
                    const watermarkDiv = printWindow.document.createElement('div');
                    watermarkDiv.className = 'watermark-overlay';

                    // Create tiled watermark pattern
                    const tiledDiv = printWindow.document.createElement('div');
                    tiledDiv.className = 'watermark-tiled';

                    // Add multiple watermark text instances for tiling effect
                    for (let j = 0; j < 30; j++) {
                        const span = printWindow.document.createElement('span');
                        span.textContent = watermarkText;
                        tiledDiv.appendChild(span);
                    }

                    watermarkDiv.appendChild(tiledDiv);
                    pageDiv.appendChild(watermarkDiv);
                }

                pagesContainer.appendChild(pageDiv);
            }
        }

        setTimeout(() => {
            printWindow.focus();
            printWindow.print();
            // Clean up iframe after print dialog closes
            setTimeout(() => {
                const frame = document.getElementById('tncfb-print-frame');
                if (frame) frame.remove();
            }, 1000);
        }, 300);
    }

    // ==========================================
    // Share Modal with QR Code
    // ==========================================

    // ==========================================
    // PDF Password Prompt
    // ==========================================

    showPasswordPrompt(updatePassword, reason) {
        const modal = document.getElementById('pdfPasswordModal');
        if (!modal) return;

        // Hide loading screen
        const loadingEl = document.getElementById('loading');
        if (loadingEl) loadingEl.style.display = 'none';

        // Show/hide error for incorrect password
        const errorEl = document.getElementById('pdfPasswordError');
        if (errorEl) {
            if (reason === 2 /* INCORRECT_PASSWORD */) {
                errorEl.classList.add('visible');
            } else {
                errorEl.classList.remove('visible');
            }
        }

        modal.classList.add('active');

        // Get elements fresh each time (no stale references)
        const getInput = () => document.getElementById('pdfPasswordInput');
        const getBtn = () => document.getElementById('pdfPasswordSubmit');

        const input = getInput();
        if (input) { input.value = ''; input.focus(); }

        // Clean up previous handler if exists
        if (this._passwordHandler) {
            this._passwordHandler.abort();
        }
        const controller = new AbortController();
        this._passwordHandler = controller;

        const doSubmit = () => {
            const inp = getInput();
            const btn = getBtn();
            const pw = inp ? inp.value : '';
            if (!pw) {
                if (inp) inp.focus();
                return;
            }
            if (btn) btn.disabled = true;
            modal.classList.remove('active');
            if (typeof a11y !== 'undefined' && a11y) a11y.releaseFocus(modal);
            controller.abort(); // Remove listeners

            // Re-show loading screen while PDF processes
            if (loadingEl) loadingEl.style.display = '';

            updatePassword(pw);
        };

        const btn = getBtn();
        if (btn) {
            btn.disabled = false;
            btn.addEventListener('click', doSubmit, { signal: controller.signal });
        }
        if (input) {
            input.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') { e.preventDefault(); doSubmit(); }
            }, { signal: controller.signal });
        }

        // Trap focus
        if (typeof a11y !== 'undefined' && a11y) a11y.trapFocus(modal);
    }

    // ==========================================
    // Share Modal with QR Code
    // ==========================================

    openShareModal() {
        // Bubble a `tncfb:share` event for analytics beacons (cloud + others).
        // Event API lives on the TNCFlipbook wrapper, not the Flipbook engine.
        this._tncfb?.emit('share', { page: this.getCurrentPageNumber() });

        const modal = document.getElementById('shareModal');
        const currentPageLabel = document.getElementById('shareCurrentPageLabel');
        const customPageInput = document.getElementById('shareCustomPageNum');

        // Update current page label
        const currentPage = this.getCurrentPageNumber();
        currentPageLabel.textContent = `Current page (${currentPage})`;

        // Set max for custom page input
        customPageInput.max = this.numPages;
        customPageInput.value = '';
        document.getElementById('shareMaxPageHint').textContent = `of ${this.numPages}`;

        // Reset to current page option
        document.querySelector('input[name="sharePage"][value="current"]').checked = true;
        document.getElementById('shareCustomPageInput').classList.remove('visible');

        // Generate URL and QR code
        this.updateShareLink();

        modal.classList.add('active');
        if (typeof a11y !== 'undefined' && a11y) a11y.trapFocus(modal);
    }

    closeShareModal() {
        const modal = document.getElementById('shareModal');
        modal.classList.remove('active');
        if (typeof a11y !== 'undefined' && a11y) a11y.releaseFocus(modal);
    }

    getShareUrl(pageOption = 'current') {
        const baseUrl = window.location.origin + window.location.pathname;
        let page = 1;

        if (pageOption === 'current') {
            page = this.getCurrentPageNumber();
        } else if (pageOption === 'start') {
            page = 1;
        } else if (pageOption === 'custom') {
            const customPage = parseInt(document.getElementById('shareCustomPageNum').value, 10);
            page = (customPage && customPage >= 1 && customPage <= this.numPages) ? customPage : 1;
        }

        // Add page parameter if not page 1
        if (page > 1) {
            return `${baseUrl}?page=${page}`;
        }
        return baseUrl;
    }

    updateShareLink() {
        const selectedOption = document.querySelector('input[name="sharePage"]:checked').value;
        const url = this.getShareUrl(selectedOption);

        document.getElementById('shareLinkInput').value = url;
        this.generateQRCode(url);
    }

    copyShareLink() {
        const url = document.getElementById('shareLinkInput').value;
        analytics.trackShare('copy_link');

        // Try modern clipboard API first
        if (navigator.clipboard && window.isSecureContext) {
            navigator.clipboard.writeText(url).then(() => {
                this.showCopySuccess();
            }).catch(() => {
                this.fallbackCopy(url);
            });
        } else {
            this.fallbackCopy(url);
        }
    }

    fallbackCopy(text) {
        // Create a temporary textarea (not readonly, not hidden via display:none)
        const textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.style.position = 'fixed';
        textarea.style.left = '-9999px';
        textarea.style.top = '-9999px';
        textarea.style.opacity = '0';
        document.body.appendChild(textarea);
        textarea.focus();
        textarea.select();

        try {
            document.execCommand('copy');
            this.showCopySuccess();
        } catch (err) {
            this.showNotification(t('notify.copyFailed'));
        }

        document.body.removeChild(textarea);
    }

    showCopySuccess() {
        const btn = document.getElementById('shareCopyBtn');
        const originalHTML = btn.innerHTML;
        btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="width:14px;height:14px"><polyline points="20 6 9 17 4 12"/></svg> ${t('share.copied')}`;
        btn.style.background = '#16a34a';
        this.showNotification(t('notify.linkCopied'));

        setTimeout(() => {
            btn.innerHTML = originalHTML;
            btn.style.background = '';
        }, 2000);
    }

    shareViaSocial(platform) {
        const url = encodeURIComponent(document.getElementById('shareLinkInput').value);
        const rawUrl = document.getElementById('shareLinkInput').value;
        const title = encodeURIComponent(document.title || 'Check out this flipbook');
        let shareUrl = '';

        switch (platform) {
            case 'facebook':
                shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
                break;
            case 'twitter':
                shareUrl = `https://twitter.com/intent/tweet?url=${url}&text=${title}`;
                break;
            case 'linkedin':
                shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`;
                break;
            case 'whatsapp':
                shareUrl = `https://wa.me/?text=${title}%20${url}`;
                break;
            case 'pinterest':
                shareUrl = `https://pinterest.com/pin/create/button/?url=${url}&description=${title}`;
                break;
            case 'email':
                shareUrl = `https://mail.google.com/mail/?view=cm&fs=1&su=${title}&body=${encodeURIComponent('Check out this flipbook: ' + rawUrl)}`;
                break;
        }

        if (shareUrl) {
            window.open(shareUrl, '_blank', 'width=600,height=400');
        }
    }

    // Simple QR Code Generator (based on QR Code Model 2)
    generateQRCode(text) {
        const canvas = document.getElementById('shareQRCanvas');
        const ctx = canvas.getContext('2d');
        const size = 160;
        const moduleCount = 25; // QR version determines this
        const moduleSize = Math.floor(size / moduleCount);
        const margin = Math.floor((size - moduleCount * moduleSize) / 2);

        // Clear canvas
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, size, size);

        // Generate QR matrix using a simple approach
        const matrix = this.createQRMatrix(text, moduleCount);

        // Draw modules
        ctx.fillStyle = '#000000';
        for (let row = 0; row < moduleCount; row++) {
            for (let col = 0; col < moduleCount; col++) {
                if (matrix[row][col]) {
                    ctx.fillRect(
                        margin + col * moduleSize,
                        margin + row * moduleSize,
                        moduleSize,
                        moduleSize
                    );
                }
            }
        }
    }

    createQRMatrix(text, size) {
        // This is a simplified QR-like pattern generator
        // For a production app, you'd use a proper QR library
        // This creates a visually similar pattern that encodes data position

        const matrix = Array(size).fill(null).map(() => Array(size).fill(false));

        // Add finder patterns (the three large squares in corners)
        this.addFinderPattern(matrix, 0, 0);
        this.addFinderPattern(matrix, size - 7, 0);
        this.addFinderPattern(matrix, 0, size - 7);

        // Add timing patterns
        for (let i = 8; i < size - 8; i++) {
            matrix[6][i] = i % 2 === 0;
            matrix[i][6] = i % 2 === 0;
        }

        // Add alignment pattern (center for small QR)
        if (size >= 25) {
            this.addAlignmentPattern(matrix, size - 9, size - 9);
        }

        // Encode data using a hash-based approach
        const hash = this.simpleHash(text);
        let bitIndex = 0;

        for (let col = size - 1; col >= 0; col -= 2) {
            if (col === 6) col = 5; // Skip timing column

            for (let row = 0; row < size; row++) {
                for (let c = 0; c < 2; c++) {
                    const actualCol = col - c;
                    if (actualCol < 0) continue;

                    // Skip reserved areas
                    if (this.isReservedModule(actualCol, row, size)) continue;

                    // Use hash to determine module state
                    const bit = (hash[bitIndex % hash.length] >> (bitIndex % 8)) & 1;
                    matrix[row][actualCol] = bit === 1;
                    bitIndex++;
                }
            }
        }

        return matrix;
    }

    addFinderPattern(matrix, startX, startY) {
        // Outer black border
        for (let i = 0; i < 7; i++) {
            matrix[startY][startX + i] = true;
            matrix[startY + 6][startX + i] = true;
            matrix[startY + i][startX] = true;
            matrix[startY + i][startX + 6] = true;
        }
        // Inner white border
        for (let i = 1; i < 6; i++) {
            matrix[startY + 1][startX + i] = false;
            matrix[startY + 5][startX + i] = false;
            matrix[startY + i][startX + 1] = false;
            matrix[startY + i][startX + 5] = false;
        }
        // Center black square
        for (let i = 2; i < 5; i++) {
            for (let j = 2; j < 5; j++) {
                matrix[startY + i][startX + j] = true;
            }
        }
    }

    addAlignmentPattern(matrix, centerX, centerY) {
        for (let i = -2; i <= 2; i++) {
            for (let j = -2; j <= 2; j++) {
                const x = centerX + j;
                const y = centerY + i;
                if (x >= 0 && x < matrix.length && y >= 0 && y < matrix.length) {
                    if (Math.abs(i) === 2 || Math.abs(j) === 2) {
                        matrix[y][x] = true;
                    } else if (i === 0 && j === 0) {
                        matrix[y][x] = true;
                    } else {
                        matrix[y][x] = false;
                    }
                }
            }
        }
    }

    isReservedModule(col, row, size) {
        // Finder patterns and their separators
        if (row < 9 && col < 9) return true;
        if (row < 9 && col >= size - 8) return true;
        if (row >= size - 8 && col < 9) return true;

        // Timing patterns
        if (row === 6 || col === 6) return true;

        // Alignment pattern area
        if (size >= 25 && row >= size - 11 && row <= size - 7 && col >= size - 11 && col <= size - 7) return true;

        return false;
    }

    simpleHash(str) {
        const result = [];
        let hash = 0;
        for (let i = 0; i < str.length; i++) {
            const char = str.charCodeAt(i);
            hash = ((hash << 5) - hash) + char;
            hash = hash & hash;
            result.push(Math.abs(hash) % 256);
        }
        // Extend to ensure enough data
        while (result.length < 100) {
            hash = ((hash << 5) - hash) + result.length;
            result.push(Math.abs(hash) % 256);
        }
        return result;
    }

    // Check for page parameter on load
    checkPageParameter() {
        // Check ?page=N query parameter
        const urlParams = new URLSearchParams(window.location.search);
        const pageParam = urlParams.get('page');
        if (pageParam) {
            const pageNum = parseInt(pageParam, 10);
            if (pageNum && pageNum >= 1 && pageNum <= this.numPages) {
                setTimeout(() => { this.goToPage(pageNum, { jump: true }); }, 100);
                return;
            }
        }

        // Navigate to start page: CONFIG.startPage > #page/N hash > page 1
        const startPage = CONFIG.startPage ? parseInt(CONFIG.startPage, 10) : null;
        // Use the hash captured at construction time — earlier updatePageIndicator()
        // calls may have replaced location.hash with #page/1.
        const hashPage = this._parsePageHash(this._initialHash);
        const targetPage = startPage || hashPage;
        if (targetPage && targetPage > 1) {
            setTimeout(() => { this.goToPage(targetPage, { jump: true }); }, 100);
        }

        // Listen for browser back/forward
        if (CONFIG.deepLinking) {
            window.addEventListener('popstate', () => {
                const p = this._parsePageHash();
                if (p && p !== this.getCurrentPageNumber()) {
                    this._deepLinkNavigating = true;
                    this.goToPage(p, { jump: true });
                }
            });
        }
    }

    /** Parse #page/N from a hash string (defaults to current location.hash). */
    _parsePageHash(hash) {
        const source = (typeof hash === 'string' ? hash : location.hash) || '';
        const match = source.match(/^#page\/(\d+)$/);
        if (match) {
            const num = parseInt(match[1], 10);
            if (num >= 1 && num <= this.numPages) return num;
        }
        return null;
    }

    /** Update URL hash to reflect current page. Called from updatePageIndicator. */
    _updateDeepLink() {
        if (!CONFIG.deepLinking) return;
        // Don't update hash if we're navigating FROM a popstate (would create duplicate entry)
        if (this._deepLinkNavigating) {
            this._deepLinkNavigating = false;
            return;
        }
        const page = this.getCurrentPageNumber();
        const newHash = '#page/' + page;
        if (location.hash !== newHash) {
            history.replaceState(null, '', newHash);
        }
    }

    setupToolbar() {
        // Set toolbar logo from CONFIG
        const toolbarLogoDark = document.querySelector('.toolbar-logo .logo-dark');
        const toolbarLogoLight = document.querySelector('.toolbar-logo .logo-light');
        if (toolbarLogoDark && CONFIG.loadingLogo) toolbarLogoDark.src = CONFIG.loadingLogo;
        if (toolbarLogoLight && CONFIG.loadingLogoDark) toolbarLogoLight.src = CONFIG.loadingLogoDark;

        // Theme toggle function
        const parseCssHex = (cssColor) => {
            const match = cssColor.match(/#([0-9a-fA-F]{6})/);
            return match ? parseInt(match[1], 16) : 0x0a0a12;
        };

        const toggleTheme = () => {
            if (this.isDarkTheme) {
                document.body.classList.add('light-theme');
                this.isDarkTheme = false;
                if (this._isTransparentBg) {
                    if (this.renderer) this.renderer.setClearColor(0xffffff, 0);
                } else {
                    if (this.renderer) this.renderer.setClearColor(parseCssHex(this.sceneBgLight), 1);
                    if (this.is2D) this.container.style.backgroundColor = this.sceneBgLight;
                }
                analytics.trackThemeChange('light');
            } else {
                document.body.classList.remove('light-theme');
                this.isDarkTheme = true;
                if (this._isTransparentBg) {
                    if (this.renderer) this.renderer.setClearColor(0x000000, 0);
                } else {
                    if (this.renderer) this.renderer.setClearColor(parseCssHex(this.sceneBgDark), 1);
                    if (this.is2D) this.container.style.backgroundColor = this.sceneBgDark;
                }
                analytics.trackThemeChange('dark');
            }
            this.updateDropShadow();
            this.updateHardcoverColors();

            // Announce theme change and update aria-pressed
            const themeLabel = this.isDarkTheme ? 'Dark theme enabled' : 'Light theme enabled';
            if (typeof a11y !== 'undefined' && a11y) a11y.announce(themeLabel);
            document.getElementById('themeToggleCtrl')?.setAttribute('aria-pressed', String(!this.isDarkTheme));
            document.getElementById('themeToggleBtn')?.setAttribute('aria-pressed', String(!this.isDarkTheme));

            // Swap loading logo for theme
            const loadingLogoEl = document.getElementById('loadingLogo');
            if (loadingLogoEl) {
                loadingLogoEl.src = this.isDarkTheme
                    ? (CONFIG.loadingLogo || '')
                    : (CONFIG.loadingLogoDark || CONFIG.loadingLogo || '');
            }

            if (this._tncfb) this._tncfb.emit('theme:change', { mode: this.isDarkTheme ? 'dark' : 'light' });
        };

        // Theme toggle in top-right controls
        document.getElementById('themeToggleCtrl').addEventListener('click', toggleTheme);

        // Theme toggle button in toolbar (for fullwidth layouts)
        document.getElementById('themeToggleBtn').addEventListener('click', toggleTheme);

        // Search
        document.getElementById('searchBtn').addEventListener('click', () => {
            const sidebar = document.getElementById('searchSidebar');
            sidebar.classList.toggle('active');
            if (sidebar.classList.contains('active')) {
                document.getElementById('searchInput').focus();
            }
        });

        document.getElementById('closeSearch').addEventListener('click', () => {
            this.closeSearch();
        });

        document.getElementById('searchClearBtn').addEventListener('click', () => {
            this.clearSearchResults();
        });

        // Search input with debounce
        let searchTimeout;
        document.getElementById('searchInput').addEventListener('input', (e) => {
            clearTimeout(searchTimeout);
            const query = e.target.value.trim();

            if (query.length < 2) {
                document.getElementById('searchResults').innerHTML = '';
                document.getElementById('searchStatus').textContent = '';
                return;
            }

            document.getElementById('searchStatus').textContent = t('search.searching');

            searchTimeout = setTimeout(() => {
                this.performSearch(query);
            }, 300);
        });

        // Enter key to go to first result
        document.getElementById('searchInput').addEventListener('keydown', (e) => {
            if (e.key === 'Enter') {
                e.preventDefault();
                if (this.searchResults.length > 0) {
                    this.goToSearchResult(0);
                }
            } else if (e.key === 'Escape') {
                this.closeSearch();
            }
        });

        // Thumbnails close button
        document.getElementById('closeThumbnails').addEventListener('click', () => {
            document.getElementById('thumbnailsSidebar').classList.remove('active');
        });

        // Fullscreen
        document.getElementById('fullscreenBtn').addEventListener('click', () => {
            if (!document.fullscreenElement) {
                document.documentElement.requestFullscreen();
                analytics.trackFullscreen(true);
            } else {
                document.exitFullscreen();
                analytics.trackFullscreen(false);
            }
        });

        // Fullscreen change event emission
        document.addEventListener('fullscreenchange', () => {
            if (this._tncfb) {
                this._tncfb.emit(document.fullscreenElement ? 'fullscreen:enter' : 'fullscreen:exit', {});
            }
        });

        // Tilt book (skew view)
        document.getElementById('tiltBtn').addEventListener('click', () => {
            if (this.isVerticalViewer || this.isHorizontalViewer) return; // Tilt not applicable in viewer modes
            if (this.book) {
                // Auto-exit reading mode if user manually tilts
                if (this.isReadingMode) {
                    // Clear tilt from pre-state so exitReadingMode doesn't restore it
                    if (this.readingModePreState) {
                        this.readingModePreState.isTilted = false;
                    }
                    this.exitReadingMode();
                }
                this.isTilted = !this.isTilted;
                this.animateBookTilt(this.isTilted ? -0.15 : 0);
                document.getElementById('tiltBtn').classList.toggle('active', this.isTilted);
                analytics.trackTilt(this.isTilted);
            }
        });

        // Rotate book 90 degrees clockwise (to the right)
        document.getElementById('rotateBtn').addEventListener('click', () => {
            this.rotationAngle = (this.rotationAngle + 90) % 360;
            analytics.trackRotation(this.rotationAngle);
            if (this.isVerticalViewer) {
                this.applyVerticalViewerRotation();
            } else if (this.isHorizontalViewer) {
                this.applyHorizontalViewerRotation();
            } else if (this.is2D || this.book) {
                this.animateBookRotation(-this.rotationAngle);
            }
        });

        // Reading mode
        document.getElementById('readingModeBtn').addEventListener('click', () => {
            this.toggleReadingMode();
        });
        document.getElementById('readingClickLeft').addEventListener('click', () => {
            if (document.body.classList.contains('scroll-reading-mode')) {
                const current = this.getCurrentPageNumber();
                if (current > 1) this.goToPage(current - 1);
            } else {
                this.flipPage(this.visualToLogical('prev'));
            }
        });
        document.getElementById('readingClickRight').addEventListener('click', () => {
            if (document.body.classList.contains('scroll-reading-mode')) {
                const current = this.getCurrentPageNumber();
                if (current < this.numPages) this.goToPage(current + 1);
            } else {
                this.flipPage(this.visualToLogical('next'));
            }
        });

        // Download PDF
        document.getElementById('downloadBtn').addEventListener('click', () => {
            // WooCommerce preview gating — refuse download in preview mode unless
            // the policy explicitly allows partial-content download.
            if (!this._checkPreviewAccess('download')) return;
            this.downloadPDF();
        });

        // Print PDF
        document.getElementById('printBtn').addEventListener('click', () => {
            if (!this._checkPreviewAccess('print')) return;
            this.printPDF();
        });

        // Zoom controls
        document.getElementById('zoomSelect').addEventListener('change', (e) => {
            const value = e.target.value;
            if (value === 'auto') {
                this.setZoom(100, 'auto');
            } else if (value === 'page-fit') {
                this.setZoom(100, 'page-fit');
            } else if (value === 'page-width') {
                this.setZoom(100, 'page-width');
            } else {
                this.setZoom(parseInt(value));
            }
        });

        document.getElementById('zoomInBtn').addEventListener('click', () => {
            this.zoomIn();
        });

        document.getElementById('zoomOutBtn').addEventListener('click', () => {
            this.zoomOut();
        });

        // Keyboard zoom shortcuts
        document.addEventListener('keydown', (e) => {
            if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
                e.preventDefault();
                this.zoomIn();
            } else if ((e.ctrlKey || e.metaKey) && e.key === '-') {
                e.preventDefault();
                this.zoomOut();
            } else if ((e.ctrlKey || e.metaKey) && e.key === '0') {
                e.preventDefault();
                this.setZoom(100);
            }
        });

        // Share functionality - opens the enhanced share modal
        const shareBtn = document.getElementById('shareBtn');
        const shareModal = document.getElementById('shareModal');

        shareBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            this.openShareModal();
        });

        // Share modal close button
        document.getElementById('shareModalClose').addEventListener('click', () => {
            this.closeShareModal();
        });

        // Close share modal when clicking outside
        shareModal.addEventListener('click', (e) => {
            if (e.target.id === 'shareModal') {
                this.closeShareModal();
            }
        });

        // Close share modal with Escape key
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && shareModal.classList.contains('active')) {
                this.closeShareModal();
            }
        });

        // Share page option changes
        document.querySelectorAll('input[name="sharePage"]').forEach(radio => {
            radio.addEventListener('change', () => {
                const customInput = document.getElementById('shareCustomPageInput');
                if (radio.value === 'custom') {
                    customInput.classList.add('visible');
                    document.getElementById('shareCustomPageNum').focus();
                } else {
                    customInput.classList.remove('visible');
                }
                this.updateShareLink();
            });
        });

        // Custom page number input
        document.getElementById('shareCustomPageNum').addEventListener('input', () => {
            this.updateShareLink();
        });

        // Copy link button
        document.getElementById('shareCopyBtn').addEventListener('click', () => {
            this.copyShareLink();
        });

        // Social share buttons in modal
        document.querySelectorAll('.share-social-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                this.shareViaSocial(btn.dataset.platform);
            });
        });

        // Legacy share popup handlers (kept for backwards compatibility)
        const sharePopup = document.getElementById('sharePopup');
        const closeSharePopup = () => {
            sharePopup.classList.remove('active');
        };

        // Share platform handlers (legacy popup)
        document.querySelectorAll('.share-option').forEach(btn => {
            btn.addEventListener('click', () => {
                const platform = btn.dataset.platform;
                const url = encodeURIComponent(window.location.href);
                const title = encodeURIComponent(document.title || 'Check out this flipbook');
                let shareUrl = '';

                switch (platform) {
                    case 'facebook':
                        shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
                        break;
                    case 'twitter':
                        shareUrl = `https://twitter.com/intent/tweet?url=${url}&text=${title}`;
                        break;
                    case 'linkedin':
                        shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`;
                        break;
                    case 'whatsapp':
                        shareUrl = `https://wa.me/?text=${title}%20${url}`;
                        break;
                    case 'email':
                        shareUrl = `mailto:?subject=${title}&body=Check%20out%20this%20flipbook:%20${url}`;
                        break;
                }

                if (shareUrl) {
                    if (platform === 'email') {
                        window.location.href = shareUrl;
                    } else {
                        window.open(shareUrl, '_blank', 'width=600,height=400');
                    }
                    closeSharePopup();
                }
            });
        });

        // Close share popup with Escape key
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && sharePopup.classList.contains('active')) {
                closeSharePopup();
            }
        });

        // More options popup functionality
        const morePopup = document.getElementById('morePopup');
        const moreBtn = document.getElementById('moreBtn');
        const morePopupArrow = morePopup.querySelector('.more-popup-arrow');

        const positionMorePopup = () => {
            const btnRect = moreBtn.getBoundingClientRect();
            const popupRect = morePopup.getBoundingClientRect();
            const isTopLayout = document.body.classList.contains('layout-top-center') ||
                               document.body.classList.contains('layout-top-fullwidth');
            const isLightTheme = document.body.classList.contains('light-theme');
            const arrowColor = getComputedStyle(document.documentElement).getPropertyValue(isLightTheme ? '--bg-popup-light' : '--bg-popup').trim();

            // Detect if popup is position:absolute (embedded mode) vs fixed (standalone)
            const popupPosition = getComputedStyle(morePopup).position;
            const isAbsolute = popupPosition === 'absolute';
            let offsetX = 0, offsetY = 0, refWidth = window.innerWidth, refHeight = window.innerHeight;
            if (isAbsolute) {
                // Get the offset parent (the container with position:relative)
                const offsetParent = morePopup.offsetParent || this.containerEl;
                if (offsetParent) {
                    const parentRect = offsetParent.getBoundingClientRect();
                    offsetX = parentRect.left;
                    offsetY = parentRect.top;
                    refWidth = parentRect.width;
                    refHeight = parentRect.height;
                }
            }

            // Center popup horizontally under the button
            const left = (btnRect.left - offsetX) + (btnRect.width / 2) - (popupRect.width / 2);

            // Ensure popup doesn't go off-screen/container
            const maxLeft = refWidth - popupRect.width - 10;
            const finalLeft = Math.max(10, Math.min(left, maxLeft));

            morePopup.style.left = `${finalLeft}px`;
            morePopup.style.transform = 'translateY(0)';

            // Position arrow to point at button center
            const arrowLeft = (btnRect.left - offsetX) + (btnRect.width / 2) - finalLeft;
            morePopupArrow.style.left = `${arrowLeft}px`;
            morePopupArrow.style.transform = 'translateX(-50%)';

            if (isTopLayout) {
                morePopup.style.top = `${(btnRect.bottom - offsetY) + 10}px`;
                morePopup.style.bottom = 'auto';
                morePopupArrow.style.bottom = 'auto';
                morePopupArrow.style.top = '-8px';
                morePopupArrow.style.borderTop = 'none';
                morePopupArrow.style.borderBottom = `10px solid ${arrowColor}`;
            } else {
                morePopup.style.bottom = `${refHeight - (btnRect.top - offsetY) + 10}px`;
                morePopup.style.top = 'auto';
                morePopupArrow.style.top = 'auto';
                morePopupArrow.style.bottom = '-8px';
                morePopupArrow.style.borderBottom = 'none';
                morePopupArrow.style.borderTop = `10px solid ${arrowColor}`;
            }
        };

        const toggleMorePopup = () => {
            const isOpening = !morePopup.classList.contains('active');
            morePopup.classList.toggle('active');
            moreBtn.classList.toggle('active');
            moreBtn.setAttribute('aria-expanded', String(isOpening));
            // Close share popup if open
            closeSharePopup();

            // Update option states when opening
            if (isOpening) {
                document.getElementById('autoPlayOption').classList.toggle('active', this.autoPlayEnabled);
                document.getElementById('singlePageOption').classList.toggle('active', this.forceSinglePage);

                // View mode options - only one can be active at a time
                const isInDefaultView = !this.isVerticalViewer && !this.isHorizontalViewer && !this.isFormViewer;
                const flipbookViewOpt = document.getElementById('flipbookViewOption');
                const verticalViewOpt = document.getElementById('verticalViewerOption');
                const horizontalViewOpt = document.getElementById('horizontalViewerOption');
                const formViewOpt = document.getElementById('formViewerOption');
                if (flipbookViewOpt) flipbookViewOpt.classList.toggle('active', isInDefaultView);
                if (verticalViewOpt) verticalViewOpt.classList.toggle('active', this.isVerticalViewer);
                if (horizontalViewOpt) horizontalViewOpt.classList.toggle('active', this.isHorizontalViewer);
                if (formViewOpt) formViewOpt.classList.toggle('active', this.isFormViewer);

                // Update flipbook style option states
                this.updateStyleOptionStates();

                // Update RTL option state

                // Update sound option label and icon
                this.updateSoundOptionDisplay();

                // Position popup below button
                positionMorePopup();
            }
        };

        const closeMorePopup = () => {
            morePopup.classList.remove('active');
            moreBtn.classList.remove('active');
        };

        moreBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            toggleMorePopup();
        });

        // Close more popup when clicking outside
        document.addEventListener('click', (e) => {
            if (!morePopup.contains(e.target) && !moreBtn.contains(e.target)) {
                closeMorePopup();
            }
        });

        // Close more popup with Escape key
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && morePopup.classList.contains('active')) {
                closeMorePopup();
            }
        });

        // Close print dialog with Escape key
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && document.getElementById('printModal').classList.contains('active')) {
                this.closePrintDialog();
            }
        });

        // More option handlers
        // Auto play
        document.getElementById('autoPlayOption').addEventListener('click', () => {
            this.toggleAutoPlay();
            document.getElementById('autoPlayOption').classList.toggle('active', this.autoPlayEnabled);
            closeMorePopup();
        });

        // Sound toggle (duplicate in more menu - syncs with main button)
        document.getElementById('soundOption').addEventListener('click', () => {
            this.toggleSound(); // This already updates the main sound button
            this.updateSoundOptionDisplay();
            closeMorePopup();
        });

        // Single page mode
        document.getElementById('singlePageOption').addEventListener('click', () => {
            // Auto-exit reading mode if user manually toggles single page
            if (this.isReadingMode) {
                this.exitReadingMode();
                return;
            }
            this.toggleSinglePageMode();
            document.getElementById('singlePageOption').classList.toggle('active', this.forceSinglePage);
            closeMorePopup();
        });

        // Bookmarks sidebar (available in Free for TOC)
        const bookmarksOption = document.getElementById('bookmarksOption');
        if (bookmarksOption) {
            bookmarksOption.addEventListener('click', () => {
                this.openBookmarksSidebar();
                closeMorePopup();
            });
        }


        // Thumbnails (same as sidebar toggle)
        document.getElementById('thumbnailsOption').addEventListener('click', () => {
            const sidebar = document.getElementById('thumbnailsSidebar');
            sidebar.classList.toggle('active');
            analytics.trackThumbnails(sidebar.classList.contains('active'));
            closeMorePopup();
        });

        // 3D Flipbook (default view) — element may not be present if admin-only
        const flipbookViewEl = document.getElementById('flipbookViewOption');
        if (flipbookViewEl) {
            flipbookViewEl.addEventListener('click', () => {
                if (this.isVerticalViewer) this.exitVerticalViewer();
                if (this.isHorizontalViewer) this.exitHorizontalViewer();
                if (this.isFormViewer) this.exitFormViewer();
                closeMorePopup();
            });
        }

        // Vertical PDF Viewer — element may not be present if admin-only
        const verticalViewEl = document.getElementById('verticalViewerOption');
        if (verticalViewEl) {
            verticalViewEl.addEventListener('click', () => {
                this.toggleVerticalViewer();
                closeMorePopup();
            });
        }

        // Horizontal PDF Viewer — element may not be present if admin-only
        const horizontalViewEl = document.getElementById('horizontalViewerOption');
        if (horizontalViewEl) {
            horizontalViewEl.addEventListener('click', () => {
                this.toggleHorizontalViewer();
                closeMorePopup();
            });
        }


        // Print dialog event handlers (elements may not exist in Lite)
        const printCancelBtn = document.getElementById('printCancelBtn');
        if (printCancelBtn) {
            printCancelBtn.addEventListener('click', () => {
                this.closePrintDialog();
            });
        }

        const printConfirmBtn = document.getElementById('printConfirmBtn');
        if (printConfirmBtn) {
            printConfirmBtn.addEventListener('click', () => {
                const radioCustom = document.querySelector('input[name="printRange"][value="custom"]');
                let pageNumbers = null;

                if (radioCustom && radioCustom.checked) {
                    const rangeInput = document.getElementById('printPagesRange').value.trim();
                    if (rangeInput) {
                        pageNumbers = this.parsePageRange(rangeInput);
                        if (pageNumbers.length === 0) {
                            this.showNotification(t('notify.invalidRange'));
                            return;
                        }
                    }
                }

                this.executePrint(pageNumbers);
            });
        }

        // Close print dialog when clicking outside
        const printModal = document.getElementById('printModal');
        if (printModal) {
            printModal.addEventListener('click', (e) => {
                if (e.target.id === 'printModal') {
                    this.closePrintDialog();
                }
            });
        }

        // Flipbook Style Options — elements may not be present if admin-only
        const styleDefaultEl = document.getElementById('styleDefaultOption');
        if (styleDefaultEl) {
            styleDefaultEl.addEventListener('click', () => {
                this.setFlipbookStyle('default');
                this.updateStyleOptionStates();
                closeMorePopup();
            });
        }

        const styleHardcoverEl = document.getElementById('styleHardcoverOption');
        if (styleHardcoverEl) {
            styleHardcoverEl.addEventListener('click', () => {
                this.setFlipbookStyle('hardcover');
                this.updateStyleOptionStates();
                closeMorePopup();
            });
        }

        const styleSpiralEl = document.getElementById('styleSpiralOption');
        if (styleSpiralEl) {
            styleSpiralEl.addEventListener('click', () => {
                this.setFlipbookStyle('spiral');
                this.updateStyleOptionStates();
                closeMorePopup();
            });
        }

        // RTL toggle — element may not be present if admin-only

        // Bookmarks sidebar close (available in Free for TOC)
        const closeBookmarksBtn = document.getElementById('closeBookmarks');
        if (closeBookmarksBtn) {
            closeBookmarksBtn.addEventListener('click', () => {
                this.closeBookmarksSidebar();
            });
        }


        // ── Overflow menu item wiring (responsive hiding handled by CSS media queries) ──
        const wire = (overflowId, action) => {
            const el = document.getElementById(overflowId);
            if (!el) return;
            el.addEventListener('click', () => {
                closeMorePopup();
                action();
            });
        };

        wire('moreShareBtn', () => this.openShareModal());
        wire('moreDownloadBtn', () => {
            if (!this._checkPreviewAccess('download')) return;
            this.downloadPDF();
        });
        wire('morePrintBtn', () => {
            if (!this._checkPreviewAccess('print')) return;
            this.printPDF();
        });
        wire('moreSearchBtn', () => {
            const sidebar = document.getElementById('searchSidebar');
            sidebar.classList.toggle('active');
            if (sidebar.classList.contains('active')) {
                document.getElementById('searchInput').focus();
            }
        });
        wire('moreReadingModeBtn', () => {
            if (this.isReadingMode) this.exitReadingMode(); else this.enterReadingMode();
        });
        wire('moreReadAloudBtn', () => {
            if (this.isReading) {
                this.togglePlayPause();
            } else {
                this.showPlayer();
                this.startReading();
            }
        });
        wire('moreZoomInBtn', () => this.zoomIn());
        wire('moreZoomOutBtn', () => this.zoomOut());
        wire('moreRotateBtn', () => this.rotatePages());
        wire('moreTiltBtn', () => {
            const btn = document.getElementById('tiltBtn');
            if (btn) btn.click();
        });
    }

    createThumbnails() {
        const grid = document.getElementById('thumbnailsGrid');
        grid.innerHTML = ''; // Clear existing thumbnails

        // Create thumbnail for each page
        for (let i = 0; i < this.numPages; i++) {
            const thumb = document.createElement('div');
            thumb.className = 'thumbnail';

            // Use stored data URL for thumbnail (lazy generated)
            const thumbUrl = this.getPageDataURL(i);
            if (thumbUrl) {
                const img = document.createElement('img');
                img.src = thumbUrl;
                img.alt = `Page ${i + 1}`;
                thumb.appendChild(img);
            }

            // Mark current spread as active
            const spreadIndex = Math.floor(i / 2);
            if (spreadIndex === this.currentSpread) {
                thumb.classList.add('active');
            }

            thumb.setAttribute('tabindex', '0');
            thumb.setAttribute('role', 'button');
            thumb.setAttribute('aria-label', `Page ${i + 1}`);
            const goToThumb = () => {
                this.goToPage(i + 1);
                document.querySelectorAll('.thumbnail').forEach(t => t.classList.remove('active'));
                thumb.classList.add('active');
            };
            thumb.addEventListener('click', goToThumb);
            thumb.addEventListener('keydown', (e) => {
                if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goToThumb(); }
            });

            grid.appendChild(thumb);
        }
    }

    onResize() {
        if (this.is2D) {
            this._2d_onResize();
            return;
        }
        this.camera.aspect = window.innerWidth / window.innerHeight;
        this.camera.updateProjectionMatrix();
        this.renderer.setSize(window.innerWidth, window.innerHeight);

        // Re-evaluate mobile single page mode
        if (CONFIG.forceSinglePageMobile) {
            const shouldBeSingle = window.innerWidth < 768;
            if (shouldBeSingle && !this._mobileSinglePageActive) {
                this._mobileSinglePageActive = true;
                if (!this.forceSinglePage) {
                    this.toggleSinglePageMode();
                }
            } else if (!shouldBeSingle && this._mobileSinglePageActive) {
                this._mobileSinglePageActive = false;
                if (this.forceSinglePage && CONFIG.defaultViewMode !== 'single') {
                    this.toggleSinglePageMode();
                }
            }
        }

        // Auto-fit book in viewport (when user hasn't manually zoomed)
        if (this.camera && this.numPages > 0 && (this.zoomMode === 'auto' || this.zoomMode === null)) {
            const fitZoom = this.calculateAutoZoom();
            if (fitZoom < 100) {
                this.zoomLevel = fitZoom;
                this.zoomMode = 'auto';
            } else if (this.zoomMode === 'auto') {
                this.zoomLevel = 100;
            }
            this.updateCameraPosition();
            this.updateZoomDisplay();
        }

        // Resize active 3D viewer canvases
        for (const id of Object.keys(this._active3DViewers)) {
            const viewer = this._active3DViewers[id];
            if (viewer.container && viewer.renderer && viewer.camera) {
                const w = viewer.container.clientWidth;
                const h = viewer.container.clientHeight;
                if (w > 0 && h > 0) {
                    viewer.camera.aspect = w / h;
                    viewer.camera.updateProjectionMatrix();
                    viewer.renderer.setSize(w, h);
                }
            }
        }
    }

    // =============================================
    // 2D CSS FALLBACK METHODS
    // =============================================

    _2d_init() {
        // Create DOM-based book container instead of Three.js scene
        this._2d_bookEl = document.createElement('div');
        this._2d_bookEl.className = 'tncfb-book-2d';
        this.container.appendChild(this._2d_bookEl);

        // Hide 3D-only UI elements
        ['peelTopRight','peelBottomRight','peelTopLeft','peelBottomLeft','tiltBtn'].forEach(id => {
            const el = document.getElementById(id);
            if (el) el.style.display = 'none';
        });

        // Apply background color from active chrome.
        const _activeChrome2D = getActiveChrome();
        this.sceneBgDark  = (_activeChrome2D.dark  || _activeChrome2D).pageBg;
        this.sceneBgLight = (_activeChrome2D.light || _activeChrome2D).pageBg;
        if (!this._isTransparentBg) {
            this.container.style.backgroundColor = this.isDarkTheme ? this.sceneBgDark : this.sceneBgLight;
        } else {
            this.container.style.backgroundColor = 'transparent';
        }

        // Click on left/right half to flip
        this._2d_bookEl.addEventListener('click', (e) => {
            if (this.isAnimating) return;
            const rect = this._2d_bookEl.getBoundingClientRect();
            const clickX = e.clientX - rect.left;
            const isRight = clickX > rect.width / 2;
            const direction = this.visualToLogical(isRight ? 'next' : 'prev');

            // Don't flip past boundaries
            const currentPage = this.getCurrentPageNumber();
            const atLast = currentPage >= this.numPages ||
                (this.isEvenPageMode && !this.forceSinglePage && currentPage + 1 >= this.numPages);
            if (direction === 'next' && atLast) return;
            if (direction === 'prev' && currentPage <= 1) return;

            this.flipPage(direction);
        });

        // Corner curl hover tracking
        this._2d_setupCornerCurl();

        // Handle resize
        window.addEventListener('resize', () => this.onResize());

        // Mark body with 2D mode class
        document.body.classList.add('tncfb-2d-mode');

        // Apply single page mode if already active (e.g. from forceSinglePageMobile)
        if (this.forceSinglePage) {
            this._2d_bookEl.classList.add('tncfb-single-page');
        }

        // Size the book container
        this._2d_onResize();

        // Update single page visibility after pages are built
        if (this.forceSinglePage && this.pages && this.pages.length > 0) {
            this._2d_updateSinglePageVisibility();
        }
    }

    _2d_setupCornerCurl() {
        this._2d_activeCurl = null; // { pageData, corner }

        // Track mouse position over container for corner proximity
        this.container.addEventListener('mousemove', (e) => {
            if (this.isAnimating) return;
            this._2d_handleMouseMove(e.clientX, e.clientY);
        });

        this.container.addEventListener('mouseleave', () => {
            this._2d_clearCurl();
        });
    }

    _2d_handleMouseMove(mx, my) {
        if (!this._2d_bookEl || !this.pages.length) return;

        const bookRect = this._2d_bookEl.getBoundingClientRect();
        const flippedCount = this.pages.filter(p => p.flipped).length;
        const atStart = flippedCount === this.minFlipped;
        const navPage = this.getCurrentPageNumber();
        const atEnd = flippedCount === this.pages.length ||
            (this.isEvenPageMode && !this.forceSinglePage && navPage + 1 >= this.numPages);

        // Determine which page face the mouse is near
        const midX = bookRect.left + bookRect.width / 2;
        const isRight = mx > midX;
        // In RTL, right side = flipped pages (prev), left side = unflipped (next)
        const isNextSide = this.isRTL ? !isRight : isRight;

        // Find the visible sheet for this side
        let targetPage = null;
        let faceEl = null;
        if (isNextSide && !atEnd) {
            targetPage = this.pages.find(p => !p.flipped);
            if (targetPage) faceEl = targetPage.frontEl;
        } else if (!isNextSide && !atStart) {
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) { targetPage = this.pages[i]; break; }
            }
            if (targetPage) faceEl = targetPage.backEl;
        }

        if (!targetPage || !faceEl) {
            this._2d_clearCurl();
            return;
        }

        // Get the face's bounding rect
        const faceRect = faceEl.getBoundingClientRect();
        if (faceRect.width === 0 || faceRect.height === 0) {
            this._2d_clearCurl();
            return;
        }

        // Calculate distance from each corner (normalize to face size)
        const relX = (mx - faceRect.left) / faceRect.width;
        const relY = (my - faceRect.top) / faceRect.height;

        // Determine nearest corner and distance
        // In RTL, the "next" page curl is on the left side, "prev" on right
        const corners = [];
        if (isNextSide) {
            // Next-page side: right corners in LTR, left corners in RTL
            if (this.isRTL) {
                corners.push({ corner: 'bl', dx: relX, dy: 1 - relY });
                corners.push({ corner: 'tl', dx: relX, dy: relY });
            } else {
                corners.push({ corner: 'br', dx: 1 - relX, dy: 1 - relY });
                corners.push({ corner: 'tr', dx: 1 - relX, dy: relY });
            }
        } else {
            // Prev-page side: left corners in LTR, right corners in RTL
            if (this.isRTL) {
                corners.push({ corner: 'br', dx: 1 - relX, dy: 1 - relY });
                corners.push({ corner: 'tr', dx: 1 - relX, dy: relY });
            } else {
                corners.push({ corner: 'bl', dx: relX, dy: 1 - relY });
                corners.push({ corner: 'tl', dx: relX, dy: relY });
            }
        }

        let nearest = null;
        let nearestDist = Infinity;
        for (const c of corners) {
            const dist = Math.sqrt(c.dx * c.dx + c.dy * c.dy);
            if (dist < nearestDist) {
                nearestDist = dist;
                nearest = c.corner;
            }
        }

        // Show curl when mouse is in the outer corner zone
        const threshold = 0.30;
        if (nearestDist > threshold) {
            this._2d_clearCurl();
            return;
        }

        // Scale curl size inversely with distance (closer = bigger curl)
        const intensity = 1 - (nearestDist / threshold);
        const smoothIntensity = intensity * intensity; // quadratic easing for gradual ramp-up
        const maxSize = Math.min(faceRect.width, faceRect.height) * 0.08;
        const curlSize = Math.round(maxSize * smoothIntensity);
        const shadowSize = Math.round(curlSize * 2);

        // Apply curl to the target face
        this._2d_showCurl(targetPage, faceEl, nearest, curlSize, shadowSize, intensity);
    }

    _2d_showCurl(pageData, faceEl, corner, curlSize, shadowSize, intensity) {
        // Ensure curl elements exist on this face
        if (!faceEl._curlEl) {
            // Curl triangle
            const curl = document.createElement('div');
            curl.className = 'tncfb-curl';
            const inner = document.createElement('div');
            inner.className = 'tncfb-curl-inner';
            curl.appendChild(inner);
            faceEl.appendChild(curl);
            faceEl._curlEl = curl;

            // Shadow behind curl
            const shadow = document.createElement('div');
            shadow.className = 'tncfb-curl-shadow';
            faceEl.appendChild(shadow);
            faceEl._curlShadow = shadow;

            // Fold gradient
            const fold = document.createElement('div');
            fold.className = 'tncfb-fold-gradient';
            faceEl.appendChild(fold);
            faceEl._foldGradient = fold;
        }

        const curlEl = faceEl._curlEl;
        const shadowEl = faceEl._curlShadow;
        const foldEl = faceEl._foldGradient;

        // Reset class and apply corner-specific class
        curlEl.className = `tncfb-curl tncfb-curl-${corner}`;
        shadowEl.className = `tncfb-curl-shadow tncfb-curl-shadow-${corner}`;

        // Fold gradient direction: right edge for right-side pages, left edge for left-side
        const isLeftCorner = corner === 'bl' || corner === 'tl';
        foldEl.className = `tncfb-fold-gradient ${isLeftCorner ? 'tncfb-fold-left' : 'tncfb-fold-right'}`;

        // Size the curl
        curlEl.style.width = curlSize + 'px';
        curlEl.style.height = curlSize + 'px';

        // Size and show shadow
        shadowEl.style.width = shadowSize + 'px';
        shadowEl.style.height = shadowSize + 'px';
        shadowEl.style.opacity = Math.min(1, intensity * 1.5);

        // Show fold edge gradient
        foldEl.style.opacity = Math.min(1, intensity * 1.0);

        // Track active state
        if (this._2d_activeCurl && this._2d_activeCurl.faceEl !== faceEl) {
            this._2d_clearCurl();
        }
        this._2d_activeCurl = { pageData, faceEl, corner };
    }

    _2d_clearCurl() {
        if (!this._2d_activeCurl) return;
        const { faceEl } = this._2d_activeCurl;
        if (faceEl._curlEl) {
            faceEl._curlEl.style.width = '0';
            faceEl._curlEl.style.height = '0';
        }
        if (faceEl._curlShadow) {
            faceEl._curlShadow.style.opacity = '0';
            faceEl._curlShadow.style.width = '0';
            faceEl._curlShadow.style.height = '0';
        }
        if (faceEl._foldGradient) {
            faceEl._foldGradient.style.opacity = '0';
        }
        this._2d_activeCurl = null;
    }

    _2d_createBook() {
        const evenOffset = this.isEvenPageMode ? 1 : 0;
        const numSheets = Math.ceil((this.numPages + evenOffset) / 2);
        this._2d_bookEl.innerHTML = '';

        for (let i = 0; i < numSheets; i++) {
            const frontPageNum = i * 2 + 1 - evenOffset;
            const backPageNum = i * 2 + 2 - evenOffset;

            const sheet = document.createElement('div');
            sheet.className = 'tncfb-sheet';
            sheet.dataset.sheet = i;

            // Faces start empty; the page canvas is painted in only for sheets
            // inside the current window (see _2d_updateSheetWindow). This avoids
            // holding a full-resolution canvas for every page in the DOM at once,
            // which on long PDFs blows past iOS Safari's per-tab memory limit and
            // triggers the reload-loop/crash.
            const front = document.createElement('div');
            front.className = 'tncfb-sheet-front';

            const back = document.createElement('div');
            back.className = 'tncfb-sheet-back';

            // Flip shadow element (cast shadow during animation)
            const flipShadow = document.createElement('div');
            flipShadow.className = 'tncfb-flip-shadow';
            sheet.appendChild(flipShadow);

            sheet.appendChild(front);
            sheet.appendChild(back);
            this._2d_bookEl.appendChild(sheet);

            // Store page data compatible with 3D pages array
            this.pages.push({
                flipped: false,
                frontPageNum,
                backPageNum,
                sheetEl: sheet,
                frontEl: front,
                backEl: back,
                // Dummy properties that 3D code may reference
                pivot: { position: { x: 0, y: 0, z: 0 }, rotation: { y: 0 } },
                currentLiftAmount: 0,
                liftAnimationId: null
            });
        }

        // Pre-flip first sheet in even page mode
        if (this.isEvenPageMode && this.pages.length > 0) {
            this.pages[0].flipped = true;
            if (this.pages[0].sheetEl) this.pages[0].sheetEl.classList.add('flipped');
        }

        // Set initial z-order
        this._2d_updateZOrder();

        // Set initial spread
        this.currentSpread = 0;
        this.updatePageIndicator();

        // Apply 2D layout class for centering
        this._2d_updateLayout();

        // Paint the initial window of face canvases.
        this._2d_updateSheetWindow();
    }

    // ── 2D sheet window (memory) ─────────────────────────────────────────────
    // 2D mode paints each page into a DOM <canvas> face. Holding one for every
    // page (a full-res copy on top of the cached source canvas) doubles canvas
    // RAM and, on long PDFs, OOMs memory-limited browsers (iOS Safari) into a
    // reload loop. So we only keep face canvases painted for sheets near the
    // current spread and release the rest, re-painting from the cached source
    // canvas on demand. Mirrors the 3D GPU texture window.

    _2d_sheetWindowRadius() {
        const r = CONFIG.lazy2dWindowRadius;
        return (typeof r === 'number' && r >= 1) ? r : 2;
    }

    // Sheet index currently at the fold (number of flipped sheets).
    _2d_currentSheetIndex() {
        if (!this.pages || !this.pages.length) return 0;
        let flipped = 0;
        for (const p of this.pages) { if (p.flipped) flipped++; }
        return Math.min(flipped, this.pages.length - 1);
    }

    // Paint one face's <canvas> from the cached source canvas (no-op if the page
    // hasn't rendered yet — it'll paint when the window next refreshes).
    _2d_paintFace(faceEl, pageNum) {
        if (!faceEl || !pageNum || pageNum < 1 || pageNum > this.numPages) return;
        const src = this.pageCanvases[pageNum - 1];
        if (!src || !src.width) return;
        let c = faceEl.querySelector('canvas');
        if (!c) { c = document.createElement('canvas'); faceEl.appendChild(c); }
        if (c.width !== src.width)   c.width = src.width;
        if (c.height !== src.height) c.height = src.height;
        c.getContext('2d').drawImage(src, 0, 0);
    }

    _2d_ensureSheetFaces(pageData) {
        if (!pageData) return;
        this._2d_paintFace(pageData.frontEl, pageData.frontPageNum);
        if (pageData.backPageNum <= this.numPages) {
            this._2d_paintFace(pageData.backEl, pageData.backPageNum);
        }
    }

    _2d_releaseSheetFaces(pageData) {
        if (!pageData) return;
        [pageData.frontEl, pageData.backEl].forEach((faceEl) => {
            if (!faceEl) return;
            const c = faceEl.querySelector('canvas');
            if (c) {
                c.width = 0; c.height = 0; // free the backing store before detaching
                c.remove();
            }
        });
    }

    // Keep faces painted for sheets within radius of the current fold; release
    // the rest. Cheap and synchronous (drawImage from an existing canvas), so it
    // runs on every navigation without a perceptible cost.
    _2d_updateSheetWindow() {
        if (!this.is2D || !this.pages || !this.pages.length) return;
        const R = this._2d_sheetWindowRadius();
        const center = this._2d_currentSheetIndex();
        const lo = center - R, hi = center + R;
        for (let i = 0; i < this.pages.length; i++) {
            if (i >= lo && i <= hi) this._2d_ensureSheetFaces(this.pages[i]);
            else this._2d_releaseSheetFaces(this.pages[i]);
        }
    }

    _2d_flipPage(direction, effect) {
        let pageIndex;
        if (direction === 'next') {
            // Don't flip past the last real page
            const currentPage = this.getCurrentPageNumber();
            if (currentPage >= this.numPages) return;
            // Even page mode spread: right page already at end
            if (this.isEvenPageMode && !this.forceSinglePage && currentPage + 1 >= this.numPages) return;

            pageIndex = this.pages.findIndex(p => !p.flipped);
            if (pageIndex === -1) return;
        } else {
            // Don't flip before minimum flipped state
            const currentFlippedCount = this.pages.filter(p => p.flipped).length;
            if (currentFlippedCount <= this.minFlipped) return;
            pageIndex = -1;
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) { pageIndex = i; break; }
            }
            if (pageIndex === -1) return;
        }

        this.isAnimating = true;
        this.playFlipSound();

        const pageData = this.pages[pageIndex];
        const sheetEl = pageData.sheetEl;

        // Add flipping class for fold gradients and cast shadow
        sheetEl.classList.add('flipping');
        this._2d_bookEl?.classList.add('tncfb-flipping');

        if (direction === 'next') {
            pageData.flipped = true;
            sheetEl.classList.add('flipped');
        } else {
            pageData.flipped = false;
            sheetEl.classList.remove('flipped');
        }

        // Update z-order immediately for proper stacking during animation
        this._2d_updateZOrder();

        // Wait for CSS transition to complete
        const duration = CONFIG.flipDuration || 800;
        setTimeout(() => {
            sheetEl.classList.remove('flipping');
            this._2d_bookEl?.classList.remove('tncfb-flipping');
            this.isAnimating = false;
            const flippedCount = this.pages.filter(p => p.flipped).length;
            this.currentSpread = Math.floor(flippedCount / 2) + 1;
            this.updatePageIndicator();
            this._2d_updateLayout();
        }, duration);
    }

    _2d_goToPage(pageNum, silent = false) {
        const targetFlipped = Math.floor((pageNum + (this.isEvenPageMode ? 1 : 0)) / 2);
        const currentFlipped = this.pages.filter(p => p.flipped).length;

        if (targetFlipped === currentFlipped) return;

        const sheetsToFlip = Math.abs(targetFlipped - currentFlipped);

        // For small jumps (1-3 sheets), use normal sequential flip
        if (sheetsToFlip <= 3) {
            const direction = targetFlipped > currentFlipped ? 'next' : 'prev';
            this._2d_animateToTarget(targetFlipped, direction, false, silent);
            return;
        }

        // For large jumps, use rapid sequential flip
        const direction = targetFlipped > currentFlipped ? 'next' : 'prev';
        this._2d_animateToTarget(targetFlipped, direction, true, silent);
    }

    _2d_animateToTarget(targetFlipped, direction, rapid = false, silent = false) {
        const currentFlipped = this.pages.filter(p => p.flipped).length;
        if (currentFlipped === targetFlipped) {
            this.isAnimating = false;
            this._2d_updateZOrder();
            this._2d_updateLayout();
            this.updatePageIndicator();
            return;
        }

        this.isAnimating = true;

        let pageIndex;
        if (direction === 'next') {
            pageIndex = this.pages.findIndex(p => !p.flipped);
        } else {
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) { pageIndex = i; break; }
            }
        }
        if (pageIndex === undefined || pageIndex === -1) {
            this.isAnimating = false;
            return;
        }

        if (!silent) this.playFlipSound();

        const pageData = this.pages[pageIndex];
        const sheetEl = pageData.sheetEl;
        const flipDuration = rapid ? CONFIG.fastFlipDuration : CONFIG.flipDuration;

        // Temporarily override CSS transition duration
        sheetEl.style.transitionDuration = flipDuration + 'ms';
        sheetEl.classList.add('flipping');
        this._2d_bookEl?.classList.add('tncfb-flipping');

        if (direction === 'next') {
            pageData.flipped = true;
            sheetEl.classList.add('flipped');
        } else {
            pageData.flipped = false;
            sheetEl.classList.remove('flipped');
        }

        this._2d_updateZOrder();

        setTimeout(() => {
            sheetEl.classList.remove('flipping');
            sheetEl.style.transitionDuration = ''; // restore CSS default

            const flippedCount = this.pages.filter(p => p.flipped).length;
            this.currentSpread = Math.floor(flippedCount / 2) + 1;
            this.updatePageIndicator();

            if (flippedCount !== targetFlipped) {
                // Chain next flip with a small gap (keep tncfb-flipping on until chain ends)
                setTimeout(() => {
                    this._2d_animateToTarget(targetFlipped, direction, rapid, true);
                }, rapid ? 10 : 50);
            } else {
                this._2d_bookEl?.classList.remove('tncfb-flipping');
                this.isAnimating = false;
                this._2d_updateLayout();
            }
        }, flipDuration);
    }

    _2d_updateZOrder() {
        if (!this.pages || !this.pages.length) return;
        const numSheets = this.pages.length;

        let lastFlippedIndex = -1;
        let firstUnflippedIndex = -1;
        for (let i = 0; i < numSheets; i++) {
            if (this.pages[i].flipped) lastFlippedIndex = i;
            else if (firstUnflippedIndex === -1) firstUnflippedIndex = i;
        }

        for (let i = 0; i < numSheets; i++) {
            const page = this.pages[i];
            if (!page.sheetEl) continue;
            if (page.flipped) {
                // Flipped sheets: most recently flipped (highest index) on top
                page.sheetEl.style.zIndex = i + 1;
            } else {
                // Unflipped sheets: first unflipped on top, decreasing
                page.sheetEl.style.zIndex = numSheets - i;
            }
        }

        // Recenter the face-canvas window on the new fold position. Runs on
        // every flip/navigation (and the initial build), so off-screen sheets'
        // canvases are released and the current spread's are painted in.
        this._2d_updateSheetWindow();
    }

    _2d_getTranslateX() {
        // Book is always centered — at-start/at-end now resize the book to single-page width
        return '-50%';
    }

    _2d_updateLayout() {
        if (!this._2d_bookEl) return;
        const flippedCount = this.pages.filter(p => p.flipped).length;
        // Even mode: start is a spread, not a single page
        const atStart = !this.isEvenPageMode && flippedCount === this.minFlipped;
        // At end: all sheets flipped
        const atEnd = flippedCount === this.pages.length;


        // In single page mode, never show spread-open
        const isSingleView = this.forceSinglePage;
        this._2d_bookEl.classList.toggle('at-start', atStart && !isSingleView);
        this._2d_bookEl.classList.toggle('at-end', atEnd && !isSingleView);
        this._2d_bookEl.classList.toggle('spread-open', !isSingleView && !atStart && !atEnd);

        // Resize book for single-page vs spread states
        this._2d_onResize();

        // Don't overwrite transform during slide animation
        if (this._2d_bookEl.classList.contains('tncfb-sliding')) return;

        // Sync the JS transform to match the layout state
        const rot = this._2d_currentRotation || 0;
        const scale = this.zoomLevel ? this.zoomLevel / 100 : 1;
        const tx = this._2d_getTranslateX();
        this._2d_bookEl.style.transform = `translate(${tx}, -50%) scale(${scale}) rotate(${rot}deg)`;
    }

    _2d_updateSinglePageVisibility() {
        if (!this.pages.length || !this._2d_bookEl) return;
        const isOddPage = this.currentSinglePage % 2 === 1;

        // Find the sheet that contains the current single page
        let visibleSheetIndex = -1;
        if (isOddPage) {
            visibleSheetIndex = this.pages.findIndex(p => !p.flipped);
        } else {
            for (let i = this.pages.length - 1; i >= 0; i--) {
                if (this.pages[i].flipped) {
                    visibleSheetIndex = i;
                    break;
                }
            }
        }
        if (visibleSheetIndex === -1) {
            visibleSheetIndex = isOddPage ? 0 : this.pages.length - 1;
            if (visibleSheetIndex < 0) visibleSheetIndex = 0;
        }

        const visiblePage = this.pages[visibleSheetIndex];

        // Show only the visible sheet, hide all others
        this.pages.forEach((p, idx) => {
            if (p.sheetEl) {
                p.sheetEl.style.display = idx === visibleSheetIndex ? '' : 'none';
            }
        });

        // In single page mode CSS disables the 3D flip, so swap face visibility:
        // Odd page → show front face, even page → show back face
        if (visiblePage) {
            if (isOddPage) {
                if (visiblePage.frontEl) visiblePage.frontEl.style.display = 'block';
                if (visiblePage.backEl) visiblePage.backEl.style.display = 'none';
            } else {
                if (visiblePage.frontEl) visiblePage.frontEl.style.display = 'none';
                if (visiblePage.backEl) visiblePage.backEl.style.display = 'block';
            }
        }
    }

    _2d_getPageScreenBounds(pageNum) {
        if (!this._2d_bookEl) return null;
        const bookRect = this._2d_bookEl.getBoundingClientRect();

        // At cover/back-cover and single page mode, the book is already single-page width
        // and sheets are set to left:0; width:100% via CSS. So the book rect IS the page rect.
        const flippedCount = this.pages.filter(p => p.flipped).length;
        const atStart = !this.isEvenPageMode && flippedCount === this.minFlipped;
        const atEnd = flippedCount === this.pages.length;

        if (this.forceSinglePage || atStart || atEnd) {
            return {
                left: bookRect.left, right: bookRect.right,
                top: bookRect.top, bottom: bookRect.bottom,
                width: bookRect.width, height: bookRect.height
            };
        }

        // Normal spread: determine which half this page is on
        const halfW = bookRect.width / 2;
        let isRightPage;
        if (this.isEvenPageMode) {
            isRightPage = (pageNum % 2 === 0);
        } else {
            isRightPage = (pageNum % 2 === 1);
        }

        const left = isRightPage ? bookRect.left + halfW : bookRect.left;
        const right = isRightPage ? bookRect.right : bookRect.left + halfW;

        return {
            left, right,
            top: bookRect.top, bottom: bookRect.bottom,
            width: right - left, height: bookRect.height
        };
    }

    _2d_onResize() {
        if (!this._2d_bookEl) return;
        // Fit book within container maintaining aspect ratio
        const containerW = this.container.clientWidth;
        const containerH = this.container.clientHeight;
        const pageAspect = CONFIG.pageWidth / CONFIG.pageHeight;

        // Determine if we're showing a single page (cover/back or forceSinglePage)
        const flippedCount = this.pages ? this.pages.filter(p => p.flipped).length : 0;
        const atStart = !this.isEvenPageMode && flippedCount === (this.minFlipped || 0);
        const atEnd = this.pages && flippedCount === this.pages.length;
        const isSingleView = this.forceSinglePage || atStart || atEnd;

        const bookAspect = isSingleView ? pageAspect : pageAspect * 2;

        let bookW, bookH;
        if (containerW / containerH > bookAspect) {
            bookH = containerH * 0.85;
            bookW = bookH * bookAspect;
        } else {
            bookW = containerW * 0.9;
            bookH = bookW / bookAspect;
        }

        this._2d_bookEl.style.width = bookW + 'px';
        this._2d_bookEl.style.height = bookH + 'px';
    }

    // True when the sheet holding pageNum is within the current face window.
    _2d_isPageInWindow(pageNum) {
        const R = this._2d_sheetWindowRadius();
        const center = this._2d_currentSheetIndex();
        const evenOffset = this.isEvenPageMode ? 1 : 0;
        const sheet = Math.floor((pageNum - 1 + evenOffset) / 2);
        return Math.abs(sheet - center) <= R;
    }

    _2d_updatePageCanvas(pageNum) {
        if (!this.pages) return;
        const canvas = this.pageCanvases[pageNum - 1];
        if (!canvas) return;
        // Only paint faces inside the window; out-of-window pages stay unpainted
        // until the window moves to them (then _2d_ensureSheetFaces paints them).
        if (!this._2d_isPageInWindow(pageNum)) return;

        for (const pageData of this.pages) {
            if (pageData.frontPageNum === pageNum && pageData.frontEl) {
                const existing = pageData.frontEl.querySelector('canvas');
                if (existing) {
                    existing.width = canvas.width;
                    existing.height = canvas.height;
                    existing.getContext('2d').drawImage(canvas, 0, 0);
                } else {
                    const c = canvas.cloneNode(false);
                    c.getContext('2d').drawImage(canvas, 0, 0);
                    pageData.frontEl.appendChild(c);
                }
            }
            if (pageData.backPageNum === pageNum && pageData.backEl) {
                const existing = pageData.backEl.querySelector('canvas');
                if (existing) {
                    existing.width = canvas.width;
                    existing.height = canvas.height;
                    existing.getContext('2d').drawImage(canvas, 0, 0);
                } else {
                    const c = canvas.cloneNode(false);
                    c.getContext('2d').drawImage(canvas, 0, 0);
                    pageData.backEl.appendChild(c);
                }
            }
        }
    }

    animate() {
        if (this.is2D) { this._rafId = null; return; } // No render loop needed for CSS-based 2D mode

        // Pause the render loop when the tab is hidden — saves CPU/GPU on idle tabs.
        // Resumed by the visibilitychange listener registered in init.
        if (CONFIG.memoryOptimized !== false && document.hidden) {
            this._rafId = null;
            return;
        }

        this._rafId = requestAnimationFrame(() => this.animate());

        // Skip rendering when vertical/horizontal viewer is active
        if (this.isVerticalViewer || this.isHorizontalViewer) return;

        // Update peel overlay positions
        this.updatePeelOverlays();

        this.renderer.render(this.scene, this.camera);
    }

    // ── WooCommerce page gating overlay ─────────────────────────────────────

        _checkPreviewAccess() { return true; }
        showGatingOverlay() {}
        closeGatingOverlay() {}
}


// =============================================
// TNCFLIPBOOK — Public entry point
// Wraps all internal classes and manages lifecycle
// =============================================
class TNCFlipbook {
    /**
     * @param {string|Element} container  CSS selector or DOM element
     * @param {object}         config     User CONFIG overrides (merged with DEFAULT_CONFIG)
     * @param {object}         options    { dependencies, workerSrc, gltfLoaderPath, orbitControlsPath,
     *                                      templatePath, template, tts, colors }
     */
    constructor(container, config = {}, options = {}) {
        // Resolve container element
        if (typeof container === 'string') {
            this.containerEl = document.querySelector(container);
        } else {
            this.containerEl = container;
        }
        if (!this.containerEl) {
            throw new Error(`TNCFlipbook: container not found: ${container}`);
        }

        // Plugin system
        this._plugins = [];

        // Instance registry
        window.__tncfb3d = window.__tncfb3d || {};
        window.__tncfb3d.instance = this;
        window.__tncfb3d.version = '1.0.0';

        // Resolve external library dependencies
        const deps = options.dependencies || {};
        THREE              = deps.THREE              || window.THREE;
        pdfjsLib           = deps.pdfjsLib           || window.pdfjsLib;
        initTranslations   = deps.initTranslations   || window.initTranslations;
        rtlLanguages       = deps.rtlLanguages       || window.rtlLanguages       || ['ar','he','fa','ur','ku','ps','sd','yi','ckb','ug'];
        availableLanguages = deps.availableLanguages || window.availableLanguages || {};

        // Configurable paths for lazy-loaded Three.js extras
        if (options.gltfLoaderPath)    _gltfLoaderPath    = options.gltfLoaderPath;
        if (options.orbitControlsPath) _orbitControlsPath = options.orbitControlsPath;

        // Merge user overrides into module-scoped config variables
        CONFIG       = _deepMerge(DEFAULT_CONFIG, config);
        TTS_CONFIG   = _deepMerge(DEFAULT_TTS_CONFIG, options.tts || {});
        COLOR_CONFIG = _deepMerge(DEFAULT_COLOR_CONFIG, options.colors || {});

        // Initialize deferred module-scope singletons now that CONFIG is set

        this._options = options;

        // Ready promise — resolves when loading:complete fires (totalPages known)
        this._readyResolved = false;
        this._readyPromise = new Promise((resolve) => { this._readyResolve = resolve; });

        // Kick off async initialisation
        this._init().catch(err => {
            console.error('TNCFlipbook init error:', err);
            this.emit('error', { message: err.message });
        });
    }

    async _init() {
        const options = this._options;

        // Configure PDF.js worker
        if (pdfjsLib && options.workerSrc) {
            pdfjsLib.GlobalWorkerOptions.workerSrc = options.workerSrc;
        }

        // Lazy-load Three.js, but only when it's actually needed:
        //  - any 3D render, OR
        //  - image-based flipbooks in ANY mode (loadImageTexture() and the
        //    placeholder both use THREE.TextureLoader / THREE.CanvasTexture).
        // PDF-based 2D / mobile-fallback loads skip the ~600 KB download + parse.
        // Back-compat: hosts that still pass dependencies.THREE keep working
        // (THREE is already set, so this is a no-op). On load failure, degrade
        // to 2D rather than breaking the viewer.
        const _isImageBased = !!(CONFIG.imageSources && CONFIG.imageSources.length > 0);
        const _needsThree = (resolveRenderMode() !== '2d') || _isImageBased;
        if (!THREE && options.threePath && _needsThree) {
            try {
                THREE = await import(options.threePath);
            } catch (e) {
                console.warn('[TNCFlipBook] Three.js failed to load; falling back to 2D mode.', e);
                if (!_isImageBased) CONFIG.renderMode = '2d';
            }
        }

        // Determine template path
        const templatePath = options.templatePath || (() => {
            const scriptUrl = import.meta.url;
            const baseDir = scriptUrl.substring(0, scriptUrl.lastIndexOf('/'));
            return baseDir + '/tnc-flipbook-template.html';
        })();

        // Inject HTML template into container
        if (options.template) {
            // Pre-loaded HTML string (useful for file:// protocol or server-side embedding)
            this.containerEl.innerHTML = options.template;
        } else {
            const response = await fetch(templatePath);
            if (!response.ok) {
                throw new Error(`TNCFlipbook: failed to load template from ${templatePath} (${response.status})`);
            }
            this.containerEl.innerHTML = await response.text();
        }

        // Rewrite relative src/href in the injected template so that ./icon.png
        // resolves against the template directory, not the current page URL.
        const templateBase = templatePath.substring(0, templatePath.lastIndexOf('/') + 1);
        this.containerEl.querySelectorAll('img[src^="./"], link[href^="./"]').forEach(el => {
            const attr = el.tagName === 'LINK' ? 'href' : 'src';
            el[attr] = templateBase + el.getAttribute(attr).substring(2);
        });

        // Initialise translations
        const { strings, fallback } = await initTranslations(CONFIG.language || 'en');
        i18nStrings  = strings;
        i18nFallback = fallback;
        applyTranslations();

        // Popup theme bias: when opened in a light-themed popup, prefer the
        // viewer's light polarity so the chrome doesn't clash with the surround.
        // Only biases when the chrome's natural polarity is dark; user can still
        // toggle via the toolbar at runtime.
        try {
            const _params = new URLSearchParams(window.location.search);
            const _popupTheme = _params.get('popuptheme');
            if (_popupTheme === 'light') {
                document.body.classList.add('tncfb-popup-theme-light');
                if (resolveColorMode(COLOR_CONFIG.chrome, COLOR_CONFIG.colorMode) === 'dark') {
                    COLOR_CONFIG.colorMode = 'light';
                }
            } else if (_popupTheme === 'dark') {
                document.body.classList.add('tncfb-popup-theme-dark');
            }
        } catch (e) {}

        // Apply chrome (surfaces) + brand color (accents). Body classes
        // (light-theme / chrome-glass / chrome-sepia) are set by applyColorScheme.
        applyColorScheme(COLOR_CONFIG.chrome, COLOR_CONFIG.brandColor, COLOR_CONFIG.colorMode);

        // Resolve render mode (3d, 2d, or auto-detect)
        const renderMode = resolveRenderMode();
        if (renderMode === '3d' && !hasWebGLSupport()) {
            throw new Error('WebGL not supported and renderMode is forced to 3d');
        }

        // Boot core flipbook and accessibility manager
        this._flipbook = new Flipbook();
        this._a11y     = new AccessibilityManager(this._flipbook);

        // Back-reference for event emission from internal Flipbook
        this._flipbook._tncfb = this;

        // Initialize registered plugins
        for (const plugin of this._plugins) {
            try { plugin.init(this); } catch (e) { console.error(`TNCFlipbook plugin "${plugin.name}" init error:`, e); }
        }

        // Note: 'ready' event is emitted after loading:complete inside the Flipbook class
        // so that totalPages is accurate. Use await fb.ready() or fb.once('ready', ...) to wait.
    }

    /** Returns a Promise that resolves when the flipbook is fully loaded (pages available) */
    ready() {
        return this._readyPromise;
    }

    // ── Event System ──────────────────────────────────────────────────────────

    /**
     * Subscribe to a flipbook event.
     * @param {string}   eventName  Event name (without 'tncfb:' prefix)
     * @param {Function} handler    Event handler receiving CustomEvent
     */
    on(eventName, handler) {
        this.containerEl.addEventListener('tncfb:' + eventName, handler);
    }

    /**
     * Unsubscribe from a flipbook event.
     */
    off(eventName, handler) {
        this.containerEl.removeEventListener('tncfb:' + eventName, handler);
    }

    /**
     * Subscribe to a flipbook event once.
     */
    once(eventName, handler) {
        this.containerEl.addEventListener('tncfb:' + eventName, handler, { once: true });
    }

    /**
     * Emit a flipbook event. Dispatches a CustomEvent on the container and
     * posts a message to the parent window when inside an iframe.
     * @param {string} eventName  Event name (without 'tncfb:' prefix)
     * @param {object} detail     Event detail data
     */
    emit(eventName, detail = {}) {
        this.containerEl.dispatchEvent(new CustomEvent('tncfb:' + eventName, { detail, bubbles: true }));

        // PostMessage bridge for iframe → parent communication
        if (window.parent !== window) {
            try {
                window.parent.postMessage({
                    type: 'tncfb-event',
                    id: CONFIG._postId || 0,
                    event: eventName,
                    detail: detail,
                }, '*');
            } catch (e) { /* cross-origin — silently ignore */ }
        }
    }

    // ── Public API — Navigation ───────────────────────────────────────────────

    goToPage(pageNum) {
        if (this._flipbook) this._flipbook.goToPage(pageNum);
    }

    nextPage() {
        if (this._flipbook) this._flipbook.flipPage('next');
    }

    prevPage() {
        if (this._flipbook) this._flipbook.flipPage('prev');
    }

    firstPage() {
        if (this._flipbook) this._flipbook.goToFirst();
    }

    lastPage() {
        if (this._flipbook) this._flipbook.goToLast();
    }

    // ── Public API — State Getters ────────────────────────────────────────────

    getCurrentPage() {
        return this._flipbook ? this._flipbook.getCurrentPageNumber() : 0;
    }

    getTotalPages() {
        return this._flipbook ? this._flipbook.numPages : 0;
    }

    getZoomLevel() {
        return this._flipbook ? (this._flipbook.zoomLevel || 100) : 100;
    }

    getViewMode() {
        if (!this._flipbook) return 'double';
        if (this._flipbook.forceSinglePage) return 'single';
        if (this._flipbook.isVerticalViewer) return 'vertical';
        if (this._flipbook.isHorizontalViewer) return 'horizontal';
        return 'double';
    }

    isFullscreen() {
        return !!document.fullscreenElement;
    }

    getConfig() {
        return Object.freeze(JSON.parse(JSON.stringify(CONFIG)));
    }

    // ── Public API — View Controls ────────────────────────────────────────────

    setZoom(level) {
        if (this._flipbook && typeof this._flipbook.setZoom === 'function') {
            this._flipbook.setZoom(level);
        }
    }

    toggleFullscreen() {
        if (!document.fullscreenElement) {
            document.documentElement.requestFullscreen().catch(() => {});
        } else {
            document.exitFullscreen().catch(() => {});
        }
    }

    setTheme(mode) {
        if (!this._flipbook) return;
        const wantLight = mode === 'light';
        const isCurrentlyDark = this._flipbook.isDarkTheme !== false;
        // Only toggle if current state differs from requested
        if ((wantLight && isCurrentlyDark) || (!wantLight && !isCurrentlyDark)) {
            // Trigger the internal theme toggle via the theme button click
            const themeBtn = document.getElementById('themeToggleCtrl') || document.getElementById('themeToggleBtn');
            if (themeBtn) themeBtn.click();
        }
    }

    // ── Public API — Content Access / Gating ─────────────────────────────────
    //
    // SECURITY NOTE — read before extending these methods.
    //
    // grantAccess() / revokeAccess() / setGating() mutate CLIENT-SIDE state only.
    // They are intended for addon use: an addon talks to a server endpoint
    // that verifies purchase/subscription status, then calls grantAccess() to
    // dismiss the gating overlay and unlock client-side navigation. They do
    // NOT request fresh content from the server.
    //
    // Bypassing these methods from the browser console DOES NOT leak protected
    // content, because the REST proxy delivers only the preview PDF / preview
    // pages to unauthenticated visitors (server-side hardening, Phase 1 of the
    // Woo preview plan). A console user who flips _accessGranted = true sees
    // the chrome unlock, but PDF.js has nothing beyond the preview pages to
    // show — the additional pages were never delivered. Likewise for
    // pre-generated v2 TTS audio, image-source files, and page-video files,
    // each gated server-side.
    //
    // To actually unlock protected content for a verified buyer, refresh the
    // page after their purchase completes — the server will then deliver the
    // full content because check_access() returns 'full'.

    /**
     * Grant full access on the client side — removes the gating overlay and
     * unlocks client-side navigation past the preview limit.
     *
     * For legitimate use from a server-coordinated addon. Will not surface any
     * content not already delivered by the server.
     */
    grantAccess() {
        if (this._flipbook) {
            this._flipbook._accessGranted = true;
            this._flipbook.closeGatingOverlay();
            this.emit('access:granted', { provider: CONFIG.gatingProvider || '' });
        }
    }

    /**
     * Revoke client-side access — re-enables the gating overlay on navigation
     * past the preview limit. Does not remove content already delivered.
     */
    revokeAccess() {
        if (this._flipbook) {
            this._flipbook._accessGranted = false;
            this.emit('access:revoked', { provider: CONFIG.gatingProvider || '' });
        }
    }

    /**
     * Check whether the user currently has full access.
     * @returns {boolean}
     */
    hasAccess() {
        if (!CONFIG.previewPages) return true; // No gating configured
        return !!(this._flipbook && this._flipbook._accessGranted);
    }

    /**
     * Get the current gating configuration.
     * @returns {{ restricted: boolean, previewPages: number, message: string, purchaseUrl: string, provider: string }}
     */
    getGatingConfig() {
        return {
            restricted:   !!CONFIG.previewPages,
            previewPages: CONFIG.previewPages || 0,
            message:      CONFIG.gatingMessage || '',
            purchaseUrl:  CONFIG.purchaseUrl || '',
            provider:     CONFIG.gatingProvider || '',
        };
    }

    /**
     * Update gating configuration at runtime (e.g., from an addon that dynamically checks access).
     * @param {object} opts  { previewPages, message, purchaseUrl, provider }
     */
    setGating(opts = {}) {
        if (opts.previewPages !== undefined)  CONFIG.previewPages  = opts.previewPages;
        if (opts.message !== undefined)       CONFIG.gatingMessage = opts.message;
        if (opts.purchaseUrl !== undefined)   CONFIG.purchaseUrl   = opts.purchaseUrl;
        if (opts.provider !== undefined)      CONFIG.gatingProvider = opts.provider;
        this.emit('gating:changed', this.getGatingConfig());
    }

    // ── Plugin System ─────────────────────────────────────────────────────────

    /**
     * Register a viewer plugin.
     * @param {object} plugin  { name: string, init(fb): void, destroy(): void }
     */
    use(plugin) {
        if (!plugin || !plugin.name) {
            console.error('TNCFlipbook: plugin must have a name property');
            return this;
        }
        this._plugins.push(plugin);
        // If already initialized, call init immediately
        if (this._flipbook) {
            try { plugin.init(this); } catch (e) { console.error(`TNCFlipbook plugin "${plugin.name}" init error:`, e); }
        }
        return this;
    }
}

export { TNCFlipbook };
export default TNCFlipbook;
