/* Melpomene webcomic reader JS */ /* Version 1.0.0_RC1 */ /* CC-BY-NC-SA : https://git.aribaud.net/caribaud/melpomene/ */ "use strict"; // ============ // CONTROLS // ============ const MOVE_NEXT = "ArrowRight"; const MOVE_BACK = "ArrowLeft"; const TOGGLE_FULLSCREEN = "F"; const TOGGLE_PROGRESSBAR = "P"; const TOGGLE_VIEW_MODE = "V"; // ======================== // NAVIGATION CONSTANTS // ======================== const MOUSEWHELL_MIN_DELAY = 50; const DELAY_BEFORE_HIDDING_CONTROLS = 4000; // ==================== // STATES CONSTANTS // ==================== const MELPOMENE_VERSION = "1.0.0_UNSTABLE"; const READER_FRAME = document.getElementById("melpomene"); const READER_CONTENT_FRAME = document.getElementById("melpomene-content-frame"); const READER_PAGES = document.getElementById("melpomene-pages"); const FOCUS_OVERLAY_HEIGHT = document.getElementById("melpomene-focus"); const FOCUS_OVERLAY_WIDTH = document.getElementById("melpomene-focus-col"); const HELP_CONTROLS = document.getElementById("melpomene-help-menu"); const PROGRESS_BAR_CONTAINER = document.getElementById("melpomene-progress-container"); const PROGRESS_BAR = document.getElementById("melpomene-progress-bar"); const PROGRESS_BAR_PAGES = document.getElementById("melpomene-progress-sections"); const VERSION_DISPLAY = document.getElementById("melpomene-version"); // ==================== // INDEX CONSTANTS // ==================== const ZOOM_PAGE_INDEX = 0; const ZOOM_WIDTH_INDEX = 1; const ZOOM_HEIGHT_INDEX = 2; const ZOOM_X_INDEX = 3; const ZOOM_Y_INDEX = 4; // =========================== // STATES GLOBAL VARIABLES // =========================== let PAGES_ZOOMS; // The variable ZOOMS can either be defined by another JS file or contructed at init if (typeof PAGES_ZOOMS === "undefined") { PAGES_ZOOMS = null; } let CURRENT_ZOOM = 0; let CURRENT_PAGE = 1; let CURRENT_WIDTH = 0; let CURRENT_HEIGHT = 0; let CURRENT_X = 0; let CURRENT_Y = 0; let IS_PAGE_MODE = false; let MOUSEWHELL_WAIT = false; // ============= // UTILITIES // ============= // Dimensions utilites // ------------------- function getPagesCount() { return READER_PAGES.childElementCount; } function pageOriginalHeight(pageNumber) { return READER_PAGES.children[pageNumber - 1].naturalHeight; } function pageOriginalWidth(pageNumber) { return READER_PAGES.children[pageNumber - 1].naturalWidth; } function readerFrameRatio() { return READER_CONTENT_FRAME.clientWidth / READER_CONTENT_FRAME.clientHeight; } function pageMaxHeight() { let maxHeight = 0; for (let pageIdx = 0; pageIdx < READER_PAGES.children.length; pageIdx += 1) { if (READER_PAGES.children[pageIdx].naturalHeight > maxHeight) { maxHeight = READER_PAGES.children[pageIdx].naturalHeight; } } return maxHeight; } function pageVerticalOffset(pageNumber) { return (pageMaxHeight() - pageOriginalHeight(pageNumber)) / 2; } function previousPagesWidth(pageNumber) { // The width of all previous pages relative to the provided index let totalWidth = 0; for (let idx = 0; idx < pageNumber - 1; idx += 1) { totalWidth += READER_PAGES.children[idx].naturalWidth; } return totalWidth; } // Zooms utilites // -------------- function globalZoomScale() { if (READER_PAGES.dataset.globalZoomScale !== undefined) { return parseFloat(READER_PAGES.dataset.globalZoomScale); } return 1.0; } function globalZoomOffsetX() { if (READER_PAGES.dataset.globalZoomOffset !== undefined) { return parseFloat(READER_PAGES.dataset.globalZoomOffset.split(",")[0]); } return 0.0; } function globalZoomOffsetY() { if (READER_PAGES.dataset.globalZoomOffset !== undefined) { return parseFloat(READER_PAGES.dataset.globalZoomOffset.split(",")[1]); } return 0.0; } function loadZoomsFromImgTagsIfRequired() { // Zooms may be defined by another JS file if (PAGES_ZOOMS === null) { PAGES_ZOOMS = []; // parse the data-zooms of each img and and the page number info for (let idx = 0; idx < READER_PAGES.children.length; idx += 1) { const zoomsRawData = READER_PAGES.children[idx].dataset.zooms; // ";" separates zooms data, "," separates values // We add the page number (adding 1 because of indexing) const zooms = zoomsRawData.split(";").map( (zoom) => [idx + 1].concat( zoom.split(",").map( (value) => parseFloat(value) ) ) ); PAGES_ZOOMS = PAGES_ZOOMS.concat(zooms); } } } function getFirstZoomOfPage(pageNumber) { for (let zoomIdx = 0; zoomIdx < PAGES_ZOOMS.length; zoomIdx += 1) { if (PAGES_ZOOMS[zoomIdx][0] === pageNumber) { return zoomIdx; } } return undefined; } function getZoomCountForPage(pageNumber) { return PAGES_ZOOMS.filter((zoom) => zoom[0] === pageNumber).length; } function getCurrentZoomIndexForPage() { const previousZoomsCount = PAGES_ZOOMS.filter((zoom) => zoom[0] < CURRENT_PAGE).length; return CURRENT_ZOOM - previousZoomsCount + 1; } function getReadingProgressPercent() { const progressPerPage = 1 / getPagesCount(); if (IS_PAGE_MODE) { return 100 * progressPerPage * CURRENT_PAGE; } const progressPerZoom = progressPerPage / getZoomCountForPage(CURRENT_PAGE); let readingProgress = (CURRENT_PAGE - 1) * progressPerPage; readingProgress += getCurrentZoomIndexForPage() * progressPerZoom; return 100 * readingProgress; } function updateProgressBar() { PROGRESS_BAR.style.width = `${getReadingProgressPercent()}%`; } // ========= // ACTIONS // ========= function updateFocusByWidth(width) { FOCUS_OVERLAY_WIDTH.style.width = `${width / READER_CONTENT_FRAME.clientWidth * 100}%`; FOCUS_OVERLAY_HEIGHT.style.height = "100%"; } function updateFocusByHeight(height) { FOCUS_OVERLAY_WIDTH.style.width = "100%"; FOCUS_OVERLAY_HEIGHT.style.height = `${height / READER_CONTENT_FRAME.clientHeight * 100}%`; } function moveReaderDisplayToArea(pageNumber, oWidth, oHeight, oPosx, oPosy) { // Keep original values for registering let width = oWidth; let height = oHeight; let posx = oPosx; let posy = oPosy; // Apply global offsets before scales if we are displaying a zoom // Pages display uses width & height = 0 if (width !== 0 || height !== 0) { width = width * globalZoomScale(); height = height * globalZoomScale(); posx = (posx + globalZoomOffsetX()) * globalZoomScale(); posy = (posy + globalZoomOffsetY()) * globalZoomScale(); } // reduce width if offset sent us outside of page if (posx < 0) { width = width + posx; posx = 0; } if (posx + width > pageOriginalWidth(pageNumber)) { width = pageOriginalWidth(pageNumber) - posx; } // reduce height if offset sent us outside of page if (posy < 0) { height = height + posy; posy = 0; } if (posy + height > pageOriginalHeight(pageNumber)) { height = pageOriginalHeight(pageNumber) - posy; } // Align the top-left corner of the frame with the page READER_PAGES.style.transform = `translate(-${previousPagesWidth(pageNumber)}px, -${pageVerticalOffset(pageNumber)}px)`; // Then move so the top-left point of the zoom match the frame top-left READER_PAGES.style.transform = `translate(${-posx}px, ${-posy}px) ${READER_PAGES.style.transform}`; // Then, scale so the zoom would fit the frame, and center the zoom if (width === 0) { width = pageOriginalWidth(pageNumber); } if (height === 0) { height = pageOriginalHeight(pageNumber); } const zoomRatio = width / height; if (readerFrameRatio() > zoomRatio) { // Frame wider than zoom => scale so heights are the same, offset on x const zoomToFrameScaleFactor = READER_CONTENT_FRAME.clientHeight / height; READER_PAGES.style.transform = `scale(${zoomToFrameScaleFactor}) ${READER_PAGES.style.transform}`; const scaledWidth = width * zoomToFrameScaleFactor; const offset = (READER_CONTENT_FRAME.clientWidth - scaledWidth) / 2; READER_PAGES.style.transform = `translateX(${offset}px) ${READER_PAGES.style.transform}`; updateFocusByWidth(scaledWidth); } else { // Frame narower than zoom => scale so left/right match, offset on y const zoomToFrameScaleFactor = READER_CONTENT_FRAME.clientWidth / width; READER_PAGES.style.transform = `scale(${zoomToFrameScaleFactor}) ${READER_PAGES.style.transform}`; const scaledHeight = height * zoomToFrameScaleFactor; const offset = (READER_CONTENT_FRAME.clientHeight - scaledHeight) / 2; READER_PAGES.style.transform = `translateY(${offset}px) ${READER_PAGES.style.transform}"`; updateFocusByHeight(scaledHeight); } // Use values before global offset / scale CURRENT_PAGE = pageNumber; CURRENT_WIDTH = oWidth; CURRENT_HEIGHT = oHeight; CURRENT_X = oPosx; CURRENT_Y = oPosy; } function refreshReaderDisplay() { moveReaderDisplayToArea( CURRENT_PAGE, CURRENT_WIDTH, CURRENT_HEIGHT, CURRENT_X, CURRENT_Y ); } function moveReaderDisplayToPage(pageNumber) { moveReaderDisplayToArea(pageNumber, 0, 0, 0, 0); } function moveReaderDisplayToZoom(zoomIdx) { moveReaderDisplayToArea( PAGES_ZOOMS[zoomIdx][ZOOM_PAGE_INDEX], PAGES_ZOOMS[zoomIdx][ZOOM_WIDTH_INDEX], PAGES_ZOOMS[zoomIdx][ZOOM_HEIGHT_INDEX], PAGES_ZOOMS[zoomIdx][ZOOM_X_INDEX], PAGES_ZOOMS[zoomIdx][ZOOM_Y_INDEX] ); CURRENT_ZOOM = zoomIdx; } function toggleViewMode() { if (IS_PAGE_MODE) { if (CURRENT_ZOOM === null) { moveReaderDisplayToZoom(getFirstZoomOfPage(CURRENT_PAGE)); } else { moveReaderDisplayToZoom(CURRENT_ZOOM); } IS_PAGE_MODE = false; } else { moveReaderDisplayToPage(CURRENT_PAGE); IS_PAGE_MODE = true; } updateProgressBar(); } function moveReader(toNext) { if (IS_PAGE_MODE) { if (toNext && CURRENT_PAGE < getPagesCount()) { moveReaderDisplayToPage(CURRENT_PAGE + 1); CURRENT_ZOOM = null; } else if (!toNext && CURRENT_PAGE > 1) { moveReaderDisplayToPage(CURRENT_PAGE - 1); CURRENT_ZOOM = null; } updateProgressBar(); } // Zoom mode else { if (toNext && CURRENT_ZOOM < PAGES_ZOOMS.length - 1) { moveReaderDisplayToZoom(CURRENT_ZOOM + 1); } else if (!toNext && CURRENT_ZOOM > 0) { moveReaderDisplayToZoom(CURRENT_ZOOM - 1); } updateProgressBar(); } } function initReader() { VERSION_DISPLAY.innerText = VERSION_DISPLAY.innerText.replace("Unknown version", MELPOMENE_VERSION); loadZoomsFromImgTagsIfRequired(); moveReaderDisplayToZoom(0); // Smoothly show pictures when they intersect with the viewport const visibilityObserver = new IntersectionObserver( (entries, _observer) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.style.opacity = 1; entry.target.style.visibility = "visible"; } else { entry.target.style.opacity = 0; entry.target.style.visibility = "hidden"; } }); }, { root: READER_CONTENT_FRAME, rootMargin: "-10px" } ); for (let pageIndex = 0; pageIndex < READER_PAGES.children.length; pageIndex += 1) { const img = READER_PAGES.children[pageIndex]; visibilityObserver.observe(img); PROGRESS_BAR_PAGES.appendChild(document.createElement("div")); } READER_PAGES.style.display = "flex"; setTimeout( () => { READER_PAGES.hidden = false; }, "300" ); setTimeout( () => { HELP_CONTROLS.style.opacity = null; HELP_CONTROLS.style.transform = null; }, DELAY_BEFORE_HIDDING_CONTROLS ); } // ============= // CALLBACKS // ============= function handleKeyPress(key) { if (key === MOVE_NEXT) { moveReader(true); } else if (key === MOVE_BACK) { moveReader(false); } else if (key.toUpperCase() === TOGGLE_FULLSCREEN) { if (document.fullscreenElement === null) { READER_FRAME.requestFullscreen(); } else { document.exitFullscreen(); } } else if (key.toUpperCase() === TOGGLE_PROGRESSBAR) { if (PROGRESS_BAR_CONTAINER.hidden === true) { PROGRESS_BAR_CONTAINER.hidden = false; } else { PROGRESS_BAR_CONTAINER.hidden = true; } refreshReaderDisplay(); } else if (key.toUpperCase() === TOGGLE_VIEW_MODE) { toggleViewMode(); } } function handleMouseWhell(event) { // Only handle scroll event if the target is the nav controls // to avoid preventing page scrolling. // Do disable page scrolling when we do prev/next, though if (!READER_FRAME.contains(event.target)) { return; } event.preventDefault(); event.stopPropagation(); if (MOUSEWHELL_WAIT) { return; } MOUSEWHELL_WAIT = true; setTimeout( () => { MOUSEWHELL_WAIT = false; }, MOUSEWHELL_MIN_DELAY ); if (event.deltaY > 0) { moveReader(true, false); } else { moveReader(false, false); } } // ====== // INIT // ====== window.addEventListener( "load", (_event) => { initReader(); } ); addEventListener( "resize", (_event) => { refreshReaderDisplay(); } ); addEventListener( "keydown", (event) => { handleKeyPress(event.key, event.shiftKey); } ); addEventListener( "wheel", (event) => { handleMouseWhell(event); }, { passive: false } );