UNPKG

@slidef/cli

Version:

CLI tool for converting PDF slides to web-viewable format

696 lines (587 loc) 21.6 kB
/** * Slidef Viewer * Handles slide navigation, progress bar, and thumbnail preview */ class SlidefViewer { constructor() { // Get slide info from URL const params = new URLSearchParams(window.location.search); // Support both /slide-name and viewer.html?slide=slide-name formats const pathParts = window.location.pathname.split('/').filter(p => p); this.slideName = pathParts[pathParts.length - 1] === 'viewer.html' ? params.get("slide") || "" : pathParts[pathParts.length - 1] || params.get("slide") || ""; this.currentSlide = parseInt(params.get("page") || "1", 10); // State this.totalSlides = 0; this.slideImages = []; this.metadata = null; this.hideControlsTimer = null; this.wheelThrottleTimer = null; // DOM elements this.slidesWrapper = document.getElementById("slides-wrapper"); this.slidePrev = document.getElementById("slide-prev"); this.slideCurrent = document.getElementById("slide-current"); this.slideNext = document.getElementById("slide-next"); this.slideLoading = document.getElementById("slide-loading"); this.navAreaPrev = document.getElementById("nav-area-prev"); this.navAreaNext = document.getElementById("nav-area-next"); this.closeButton = document.getElementById("close-button"); this.scrollModeToggle = document.getElementById("scroll-mode-toggle"); this.overviewButton = document.getElementById("overview-button"); this.shareButton = document.getElementById("share-button"); this.fullscreenToggle = document.getElementById("fullscreen-toggle"); this.progressBar = document.getElementById("progress-bar"); this.progressFill = document.getElementById("progress-fill"); this.thumbnailPreview = document.getElementById("thumbnail-preview"); this.thumbnailImage = document.getElementById("thumbnail-image"); this.thumbnailNumber = document.getElementById("thumbnail-number"); this.scrollContainer = document.getElementById("scroll-container"); this.overviewModal = document.getElementById("overview-modal"); this.overviewModalClose = document.getElementById("overview-modal-close"); this.overviewGrid = document.getElementById("overview-grid"); this.shareModal = document.getElementById("share-modal"); this.shareModalClose = document.getElementById("share-modal-close"); this.shareLinkInput = document.getElementById("share-link-input"); this.shareEmbedInput = document.getElementById("share-embed-input"); this.copyLinkButton = document.getElementById("copy-link-button"); this.copyEmbedButton = document.getElementById("copy-embed-button"); // Setup image load event handlers this.setupImageLoadHandlers(); this.init(); } setupImageLoadHandlers() { [this.slidePrev, this.slideCurrent, this.slideNext].forEach((img) => { img.addEventListener("load", () => { img.classList.remove("loading"); this.checkAllImagesLoaded(); }); img.addEventListener("error", () => { img.classList.remove("loading"); this.checkAllImagesLoaded(); }); }); } checkAllImagesLoaded() { const anyLoading = [this.slidePrev, this.slideCurrent, this.slideNext].some( (img) => img.classList.contains("loading") && img.src ); if (!anyLoading) { this.slideLoading.style.display = "none"; } } async init() { try { await this.loadMetadata(); this.setupEventListeners(); this.showSlide(this.currentSlide); // Update URL if page query is missing const params = new URLSearchParams(window.location.search); if (!params.has("page")) { const url = new URL(window.location); url.searchParams.set("page", this.currentSlide); window.history.replaceState({}, "", url); } // Enable scroll mode by default on mobile or if mode=scroll in URL const mode = params.get("mode"); if (window.innerWidth <= 768 || mode === "scroll") { this.toggleScrollMode(); } // Show close button only if coming from list page const isFromList = params.get("from") === "list"; if (!isFromList) { this.closeButton.parentElement.style.display = "none"; } } catch (error) { console.error("Failed to initialize viewer:", error); } } async loadMetadata() { const baseUrl = window.BASE_URL || ""; const response = await fetch(`${baseUrl}/slides/${this.slideName}/metadata.json`); if (!response.ok) { throw new Error("Failed to load metadata"); } this.metadata = await response.json(); this.totalSlides = this.metadata.pageCount; // Generate image paths const format = this.metadata.format || "webp"; this.slideImages = Array.from({ length: this.totalSlides }, (_, i) => { const pageNum = String(i + 1).padStart(3, "0"); return `${baseUrl}/slides/${this.slideName}/images/slide-${pageNum}.${format}`; }); } setupEventListeners() { // Navigation areas this.navAreaPrev.addEventListener("click", () => this.previousSlide()); this.navAreaNext.addEventListener("click", () => this.nextSlide()); // Close button this.closeButton.addEventListener("click", () => { const baseUrl = window.BASE_URL || ""; window.location.href = baseUrl ? `${baseUrl}/` : "/"; }); // Scroll mode toggle this.scrollModeToggle.addEventListener("click", () => this.toggleScrollMode() ); // Overview button this.overviewButton.addEventListener("click", () => this.openOverviewModal() ); // Share button this.shareButton.addEventListener("click", () => this.openShareModal()); // Fullscreen toggle this.fullscreenToggle.addEventListener("click", () => this.toggleFullscreen() ); // Overview modal close this.overviewModalClose.addEventListener("click", () => this.closeOverviewModal() ); // Prevent wheel events from affecting overview modal scroll this.overviewModal.addEventListener( "wheel", (e) => { e.stopPropagation(); }, { passive: true } ); // Prevent wheel events from affecting share modal scroll this.shareModal.addEventListener( "wheel", (e) => { e.stopPropagation(); }, { passive: true } ); // Share modal close this.shareModalClose.addEventListener("click", () => this.closeShareModal() ); this.shareModal.addEventListener("click", (e) => { if (e.target === this.shareModal) { this.closeShareModal(); } }); // Copy buttons this.copyLinkButton.addEventListener("click", () => this.copyToClipboard(this.shareLinkInput.value, "Link copied!") ); this.copyEmbedButton.addEventListener("click", () => this.copyToClipboard(this.shareEmbedInput.value, "Embed code copied!") ); // Share mode radio buttons - update URLs when changed document.querySelectorAll('input[name="share-mode"]').forEach((radio) => { radio.addEventListener("change", () => this.updateShareURLs()); }); // Keyboard navigation document.addEventListener("keydown", (e) => this.handleKeyPress(e)); // Mouse wheel navigation (only in slide mode, not scroll mode) document.addEventListener("wheel", (e) => this.handleWheel(e), { passive: false, }); // Progress bar click this.progressBar.addEventListener("click", (e) => this.handleProgressClick(e) ); // Progress bar hover for thumbnail this.progressBar.addEventListener("mousemove", (e) => this.showThumbnail(e) ); this.progressBar.addEventListener("mouseleave", () => this.hideThumbnail()); // Prevent context menu on slide images this.slidePrev.addEventListener("contextmenu", (e) => e.preventDefault()); this.slideCurrent.addEventListener("contextmenu", (e) => e.preventDefault() ); this.slideNext.addEventListener("contextmenu", (e) => e.preventDefault()); // Update URL on slide change window.addEventListener("popstate", () => { const params = new URLSearchParams(window.location.search); const page = parseInt(params.get("page") || "1", 10); this.showSlide(page, false); }); // Listen for fullscreen changes document.addEventListener("fullscreenchange", () => { this.updateFullscreenIcon(!!document.fullscreenElement); }); document.addEventListener("webkitfullscreenchange", () => { this.updateFullscreenIcon(!!document.webkitFullscreenElement); }); // Show/hide controls on mouse move document.addEventListener("mousemove", () => this.showControls()); // Initial show this.showControls(); } showControls() { document.body.classList.add("controls-visible"); // Clear existing timer if (this.hideControlsTimer) { clearTimeout(this.hideControlsTimer); } // Hide after 3 seconds this.hideControlsTimer = setTimeout(() => { document.body.classList.remove("controls-visible"); }, 3000); } handleKeyPress(e) { // Handle ESC key for closing modals if (e.key === "Escape") { if (!this.overviewModal.classList.contains("hidden")) { e.preventDefault(); this.closeOverviewModal(); return; } if (!this.shareModal.classList.contains("hidden")) { e.preventDefault(); this.closeShareModal(); return; } } switch (e.key) { case "ArrowLeft": case "ArrowUp": case "PageUp": e.preventDefault(); this.previousSlide(); break; case "ArrowRight": case "ArrowDown": case "PageDown": case " ": e.preventDefault(); this.nextSlide(); break; case "Home": e.preventDefault(); this.goToSlide(1); break; case "End": e.preventDefault(); this.goToSlide(this.totalSlides); break; } } handleWheel(e) { // Don't handle wheel in scroll mode or when modal is open if ( document.body.classList.contains("scroll-mode") || !this.overviewModal.classList.contains("hidden") ) { return; } // Prevent default scroll behavior e.preventDefault(); // Throttle wheel events to avoid too rapid navigation if (this.wheelThrottleTimer) { return; } this.wheelThrottleTimer = setTimeout(() => { this.wheelThrottleTimer = null; }, 300); // Navigate based on wheel direction if (e.deltaY > 0) { // Scroll down = next slide this.nextSlide(); } else if (e.deltaY < 0) { // Scroll up = previous slide this.previousSlide(); } } handleProgressClick(e) { const rect = this.progressBar.getBoundingClientRect(); const x = e.clientX - rect.left; const percentage = x / rect.width; const targetSlide = Math.max( 1, Math.min(this.totalSlides, Math.ceil(percentage * this.totalSlides)) ); this.goToSlide(targetSlide); } showThumbnail(e) { const rect = this.progressBar.getBoundingClientRect(); const x = e.clientX - rect.left; const percentage = x / rect.width; const targetSlide = Math.max( 1, Math.min(this.totalSlides, Math.ceil(percentage * this.totalSlides)) ); // Position thumbnail this.thumbnailPreview.style.left = `${e.clientX}px`; this.thumbnailPreview.style.transform = "translateX(-50%)"; // Update thumbnail this.thumbnailImage.src = this.slideImages[targetSlide - 1]; this.thumbnailNumber.textContent = `${targetSlide} / ${this.totalSlides}`; this.thumbnailPreview.classList.remove("hidden"); } hideThumbnail() { this.thumbnailPreview.classList.add("hidden"); } previousSlide() { if (this.currentSlide > 1) { this.goToSlide(this.currentSlide - 1); } } nextSlide() { if (this.currentSlide < this.totalSlides) { this.goToSlide(this.currentSlide + 1); } } goToSlide(slideNumber) { this.showSlide(slideNumber, true); } showSlide(slideNumber, updateHistory = true) { this.currentSlide = Math.max(1, Math.min(slideNumber, this.totalSlides)); // Reset wrapper transform this.slidesWrapper.style.transform = "translateX(0)"; this.slidesWrapper.style.transition = "none"; // Show loading spinner this.slideLoading.style.display = "block"; // Add loading class to images that will change this.slideCurrent.classList.add("loading"); // Update current slide this.slideCurrent.src = this.slideImages[this.currentSlide - 1]; // Update previous slide if (this.currentSlide > 1) { this.slidePrev.classList.add("loading"); this.slidePrev.src = this.slideImages[this.currentSlide - 2]; this.slidePrev.style.display = "block"; } else { this.slidePrev.style.display = "none"; } // Update next slide if (this.currentSlide < this.totalSlides) { this.slideNext.classList.add("loading"); this.slideNext.src = this.slideImages[this.currentSlide]; this.slideNext.style.display = "block"; } else { this.slideNext.style.display = "none"; } // Update progress const progress = (this.currentSlide / this.totalSlides) * 100; this.progressFill.style.width = `${progress}%`; // Update navigation areas if (this.currentSlide === 1) { this.navAreaPrev.style.display = "none"; } else { this.navAreaPrev.style.display = "block"; } if (this.currentSlide === this.totalSlides) { this.navAreaNext.style.display = "none"; } else { this.navAreaNext.style.display = "block"; } // Update URL if (updateHistory) { const url = new URL(window.location); url.searchParams.set("page", this.currentSlide); window.history.replaceState({}, "", url); } // Update document title document.title = `${this.metadata.title || this.metadata.name} - Slide ${ this.currentSlide }`; } toggleFullscreen() { const elem = document.documentElement; if (!document.fullscreenElement && !document.webkitFullscreenElement) { // Enter fullscreen if (elem.requestFullscreen) { elem.requestFullscreen().catch((err) => { console.error("Failed to enter fullscreen:", err); }); } else if (elem.webkitRequestFullscreen) { elem.webkitRequestFullscreen(); } } else { // Exit fullscreen if (document.exitFullscreen) { document.exitFullscreen().catch((err) => { console.error("Failed to exit fullscreen:", err); }); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } } } updateFullscreenIcon(isFullscreen) { const enterIcon = document.querySelector(".fullscreen-enter-icon"); const exitIcon = document.querySelector(".fullscreen-exit-icon"); if (isFullscreen) { enterIcon.classList.add("hidden"); exitIcon.classList.remove("hidden"); } else { enterIcon.classList.remove("hidden"); exitIcon.classList.add("hidden"); } } toggleScrollMode() { const isScrollMode = document.body.classList.toggle("scroll-mode"); // Update icon const slideIcon = document.querySelector(".slide-mode-icon"); const scrollIcon = document.querySelector(".scroll-mode-icon"); if (isScrollMode) { slideIcon.classList.remove("hidden"); scrollIcon.classList.add("hidden"); this.enterScrollMode(); } else { slideIcon.classList.add("hidden"); scrollIcon.classList.remove("hidden"); this.exitScrollMode(); } } enterScrollMode() { // Clear scroll container this.scrollContainer.innerHTML = ""; // Add all slides to scroll container for (let i = 1; i <= this.totalSlides; i++) { const slideImg = document.createElement("img"); slideImg.className = "scroll-slide"; slideImg.src = this.slideImages[i - 1]; slideImg.alt = `Slide ${i}`; slideImg.loading = "lazy"; this.scrollContainer.appendChild(slideImg); } this.scrollContainer.classList.remove("hidden"); // Add scroll event listener to update progress bar this.scrollContainer.addEventListener("scroll", () => this.updateScrollProgress() ); // Scroll to current slide position this.scrollToCurrentSlide(); } exitScrollMode() { // Calculate which slide is currently visible before exiting this.updateCurrentSlideFromScroll(); this.scrollContainer.classList.add("hidden"); // Remove scroll event listener this.scrollContainer.removeEventListener("scroll", () => this.updateScrollProgress() ); // Show the current slide in slide mode this.showSlide(this.currentSlide, true); } scrollToCurrentSlide() { // Calculate scroll position for current slide const slideElements = this.scrollContainer.querySelectorAll(".scroll-slide"); if (slideElements.length > 0 && this.currentSlide > 0) { const targetSlide = slideElements[this.currentSlide - 1]; if (targetSlide) { targetSlide.scrollIntoView({ behavior: "auto", block: "center" }); } } } updateScrollProgress() { // Update current slide based on scroll position this.updateCurrentSlideFromScroll(); // Update progress bar based on slide number, not scroll position const progress = (this.currentSlide / this.totalSlides) * 100; this.progressFill.style.width = `${progress}%`; } updateCurrentSlideFromScroll() { // Find which slide is currently most visible const slideElements = this.scrollContainer.querySelectorAll(".scroll-slide"); const scrollTop = this.scrollContainer.scrollTop; const containerHeight = this.scrollContainer.clientHeight; const centerY = scrollTop + containerHeight / 2; let closestSlide = 1; let minDistance = Infinity; slideElements.forEach((slide, index) => { const slideTop = slide.offsetTop; const slideCenter = slideTop + slide.offsetHeight / 2; const distance = Math.abs(slideCenter - centerY); if (distance < minDistance) { minDistance = distance; closestSlide = index + 1; } }); this.currentSlide = closestSlide; } openShareModal() { // Update share URLs when modal opens this.updateShareURLs(); // Show modal this.shareModal.classList.remove("hidden"); } updateShareURLs() { // Get current URL and remove 'from' parameter const url = new URL(window.location.href); url.searchParams.delete("from"); // Get selected mode const selectedMode = document.querySelector( 'input[name="share-mode"]:checked' ).value; // Add mode parameter if scroll mode is selected if (selectedMode === "scroll") { url.searchParams.set("mode", "scroll"); } else { url.searchParams.delete("mode"); } const shareLink = url.toString(); this.shareLinkInput.value = shareLink; // Generate embed code (Speaker Deck style) const embedCode = `<iframe class="slidef-iframe" frameborder="0" src="${shareLink}" title="${ this.metadata.title || this.metadata.name }" allowfullscreen="true" style="border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;" data-ratio="1.7777777777777777"></iframe>`; this.shareEmbedInput.value = embedCode; } closeShareModal() { this.shareModal.classList.add("hidden"); } openOverviewModal() { // Clear existing grid this.overviewGrid.innerHTML = ""; // Generate grid items for all slides for (let i = 1; i <= this.totalSlides; i++) { const slideItem = document.createElement("div"); slideItem.className = "overview-slide"; if (i === this.currentSlide) { slideItem.classList.add("active"); } const slideImg = document.createElement("img"); slideImg.className = "overview-slide-image"; slideImg.src = this.slideImages[i - 1]; slideImg.alt = `Slide ${i}`; slideImg.loading = "lazy"; const slideNumber = document.createElement("div"); slideNumber.className = "overview-slide-number"; slideNumber.textContent = i; slideItem.appendChild(slideImg); slideItem.appendChild(slideNumber); // Click to navigate slideItem.addEventListener("click", () => { this.goToSlide(i); this.closeOverviewModal(); }); this.overviewGrid.appendChild(slideItem); } // Show modal this.overviewModal.classList.remove("hidden"); } closeOverviewModal() { this.overviewModal.classList.add("hidden"); } async copyToClipboard(text, successMessage) { try { await navigator.clipboard.writeText(text); // Show temporary success feedback const button = event.target; const originalText = button.textContent; button.textContent = successMessage; setTimeout(() => { button.textContent = originalText; }, 2000); } catch (err) { console.error("Failed to copy:", err); alert("Failed to copy to clipboard"); } } } // Initialize viewer when DOM is ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => new SlidefViewer()); } else { new SlidefViewer(); }