// ==UserScript== // @name Usability Tweaks for Manga sites. // @namespace Itsnotlupus Industries // @match https://asura.gg/* // @match https://flamescans.org/* // @match https://void-scans.com/* // @match https://luminousscans.com/* // @match https://shimascans.com/* // @match https://nightscans.org/* // @match https://freakscans.com/* // @match https://mangastream.themesia.com/* // @noframes // @version 1.14 // @author Itsnotlupus // @license MIT // @description Keyboard navigation, inertial drag scrolling, chapter preloading and chapter tracking for MangaStream sites, like Asura Scans, Flame Scans, Void Scans, Luminous Scans, Shima Scans, Night Scans, Freak Scans. // @run-at document-start // @require https://greasyfork.org/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js // @grant GM_setValue // @grant GM_getValue // @downloadURL none // ==/UserScript== /* not currently supported, but might later: realmscans.xyz manhwafreak.com manhwafreak-fr.com */ /* jshint esversion:11 */ /* eslint curly: 0 no-return-assign: 0, no-loop-func: 0 */ /* global fixConsole, addStyles, $, $$, $$$, events, rAF, observeDOM, untilDOM, until, fetchHTML, prefetch, crel */ // fixConsole(); // TODO: reorganize this mess somehow. const CURSOR_PREV = 'data:image/svg+xml;base64,'+btoa` `; const CURSOR_NEXT = 'data:image/svg+xml;base64,'+btoa` `; addStyles(` /* remove ads and blank space between images were ads would have been */ [class^="ai-viewport"], .code-block, .blox, .kln, [id^="teaser"] { display: none !important; } /* hide various header and footer content. */ .socialts, .chdesc, .chaptertags, .postarea >#comments, .postbody>article>#comments { display: none; } /* asura broke some of MangaStream's CSS. whatever. */ .black #thememode { display: none } /* style a custom button to expand collapsed footer areas */ button.expand { float: right; border: 0; border-radius: 20px; padding: 2px 15px; font-size: 13px; line-height: 25px; background: #333; color: #888; font-weight: bold; cursor: pointer; } button.expand:hover { background: #444; } /* disable builtin drag behavior to allow drag scrolling and swiping */ * { user-select: none; -webkit-user-drag: none; } /* nav swiping cursors */ body.prev.prev { cursor: url("${CURSOR_PREV}") 16 16, auto !important; } body.next.next { cursor: url("${CURSOR_NEXT}") 16 16, auto !important; } /* drag scrolling cursor */ body.drag { cursor: grabbing !important; } /* add a badge on bookmark items showing the number of unread chapters */ .unread-badge { position: absolute; top: 0; right: 0; z-index: 9999; display: block; padding: 2px; margin: 5px; border: 1px solid #0005b1; border-radius: 12px; background: #ffc700; color: #0005b1; font-weight: bold; font-family: cursive; transform: rotate(10deg); width: 24px; height: 24px; line-height: 18px; text-align: center; } .soralist .unread-badge { position: initial; display: inline-block; zoom: 0.8; } /* luminousscans junk */ .flame, .flamewidthwrap { display: none !important } .listupd .bs .bsx .limit .type { right: initial; left: 5px } /* animate transitions to/from portrait modes */ html { transition: .25s transform; width: calc(100vw - 17px); } `); // keyboard navigation. good for long strips, which is apparently all this site has. const prev = () => $`.ch-prev-btn`?.click(); const next = () => $`.ch-next-btn`?.click(); addEventListener('keydown', e => document.activeElement.tagName != 'INPUT' && ({ ArrowLeft: prev, ArrowRight: next, KeyA: prev, KeyD: next, KeyK: () => rotatePage(true), // clockwise KeyL: () => rotatePage(false), // counter-clockwise }[e.code]?.()), true); // inertial drag scrolling, swipe navigation, some rotation logic. let orientation = 0; // degrees. one of [0, 90, 180, 270 ]; let rotating = false; // disable rotation until rotating is false again. const wheelFactor = { 0:0, 90: 1, 180: 0, 270: -1 }; let [ delta, drag, dragged, navPos ] = [0, false, false, 0]; let previousTouch; const eventListeners = { // wheel event, used to handle landscape scrolling wheel: e => scrollBy(e.wheelDeltaY * wheelFactor[orientation], 0), // mouse dragging/swiping mousedown: () => { [ delta, drag, dragged, navPos ] = [0, true, false, 0]; }, mousemove: e => { if (drag) { if (wheelFactor[orientation]) { scrollBy(delta=-e.movementX, 0); } else { scrollBy(0, delta=-e.movementY); } if (!wheelFactor[orientation]) { // nav swiping is just too confusing in landscape mode. const width = $`#readerarea`.clientWidth; navPos+=e.movementX; if (navPos < -width/4) document.body.classList.add("prev"); else if (navPos > width/4) document.body.classList.add("next"); else document.body.classList.remove("prev", "next"); } if (Math.abs(delta)>3) { dragged = true; document.body.classList.add('drag'); } } }, mouseup: () => { if (drag) { drag=false; rAF((_, next) => Math.abs(delta*=0.98)>1 && next(wheelFactor[orientation]?scrollBy(delta,0):scrollBy(0, delta))); } const goPrev = document.body.classList.contains("prev"); const goNext = document.body.classList.contains("next"); document.body.classList.remove("prev", "next", "drag"); if (dragged) { dragged = false; addEventListener('click', e => { if (goPrev || goNext) return; e.preventDefault(); e.stopPropagation(); }, { capture: true, once: true }); } if (goPrev) prev(); if (goNext) next(); } }; events(eventListeners); // don't be shy about loading an entire chapter untilDOM(()=>$$`img[loading="lazy"]`).then(images=>images.forEach(img => img.loading="eager")); // retry loading broken images const imgBackoff = new Map(); const imgNextRetry = new Map(); const retryImage = img => { console.log("RETRY LOADING IMAGE! ",img.src, {complete:img.complete, naturalHeight:img.naturalHeight}, img); const now = Date.now(); const nextRetry = imgNextRetry.has(img) ? imgNextRetry.get(img) : (imgNextRetry.set(img, now),now); if (nextRetry <= now) { // exponential backoff between retries: 0ms, 250ms, 500ms, 1s, 2s, 4s, 8s, 10s, 10s, ... imgBackoff.set(img, Math.min(10000,(imgBackoff.get(img)??125)*2)); imgNextRetry.set(img, now + imgBackoff.get(img)); img.src=img.src; } else { setTimeout(()=>retryImage(img), nextRetry - now); } } events({ load() { observeDOM(() => { [...document.images].filter(img=>img.complete && !img.naturalHeight && getComputedStyle(img).display!='none').forEach(retryImage); }); }}); // and prefetch the next chapter's images for even less waiting. untilDOM(`a.ch-next-btn[href^="http"]`).then(a => fetchHTML(a.href).then(doc => [...doc.images].forEach(img => prefetch(img.src)))); // have bookmarks track the last chapter you read // NOTE: If you use TamperMonkey, you can use their "Utilities" thingy to export/import this data across browsers/devices // (I wish this was an automatic sync tho.) const LAST_READ_CHAPTER_KEY = `${location.hostname}/lastReadChapterKey`; const SERIES_ID_HREF_MAP = `${location.hostname}/seriesIdHrefMap`; const SERIES_ID_LATEST_MAP = `${location.hostname}/seriesIdLatestMap`; const BOOKMARK = `${location.hostname}/bookmark`; const BOOKMARK_HTML = `${location.hostname}/bookmarkHTML`; // backward-compatibility - going away soon. const X_LAST_READ_CHAPTER_KEY = "lastReadChapter"; const X_SERIES_ID_HREF_MAP = "seriesIdHrefMap"; const X_SERIES_ID_LATEST_MAP = "seriesIdLatestMap"; const lastReadChapters = GM_getValue(LAST_READ_CHAPTER_KEY, GM_getValue(X_LAST_READ_CHAPTER_KEY, JSON.parse(localStorage.getItem(X_LAST_READ_CHAPTER_KEY) ?? "{}"))); const seriesIdHrefMap = GM_getValue(SERIES_ID_HREF_MAP, GM_getValue(X_SERIES_ID_HREF_MAP, JSON.parse(localStorage.getItem(X_SERIES_ID_HREF_MAP) ?? "{}"))); const seriesIdLatestMap = GM_getValue(SERIES_ID_LATEST_MAP, GM_getValue(X_SERIES_ID_LATEST_MAP, JSON.parse(localStorage.getItem(X_SERIES_ID_LATEST_MAP) ?? "{}"))); // sync site bookmarks into userscript data. // rules: // 1. A non-empty usBookmarks is always correct on start. // 2. any changes to localStorage while the page is loaded updates usBookmarks. const usBookmarks = GM_getValue(BOOKMARK, GM_getValue('bookmark', [])); if (usBookmarks.length) { localStorage.bookmark = JSON.stringify(usBookmarks); } else { GM_setValue(BOOKMARK, JSON.parse(localStorage.bookmark ?? '[]')); } (async function watchBookmarks() { let lsb = localStorage.bookmark; while (true) { await until(() => lsb !== localStorage.bookmark); lsb = localStorage.bookmark; GM_setValue(BOOKMARK, JSON.parse(lsb)); } })(); function getLastReadChapter(post_id, defaultValue = {}) { return lastReadChapters[post_id] ?? defaultValue; } function setLastReadChapter(post_id, chapter_id, chapter_number) { lastReadChapters[post_id] = { id: chapter_id, number: chapter_number }; GM_setValue(LAST_READ_CHAPTER_KEY, lastReadChapters); } function getSeriesId(post_id, href) { if (post_id) { seriesIdHrefMap[href] = post_id; GM_setValue(SERIES_ID_HREF_MAP, seriesIdHrefMap); } else { post_id = seriesIdHrefMap[href]; } return post_id; } function getLatestChapter(post_id, chapter) { if (chapter) { seriesIdLatestMap[post_id] = chapter; GM_setValue(SERIES_ID_LATEST_MAP, seriesIdLatestMap); } else { chapter = seriesIdLatestMap[post_id]; } return chapter; } // new UI elements function makeCollapsedFooter({ label, section }) { const elt = crel('div', { className: 'bixbox', style: 'padding: 8px 15px' }, crel('button', { className: 'expand', textContent: label, onclick() { section.style.display = 'block'; elt.style.display = 'none'; } })); section.parentElement.insertBefore(elt, section); } // series card decorations, used in bookmarks and manga lists pages. const CHAPTER_REGEX = /\bChapter (?\d+)\b|\bch.(?\d+)\b/i; async function decorateCards(reorder = true) { const cards = await untilDOM(() => $$$("//div[contains(@class, 'listupd')]//div[contains(@class, 'bsx')]/..")); cards.reverse().forEach(b => { const post_id = getSeriesId(b.firstElementChild.dataset.id, $('a', b).href); const epxs = $('.epxs',b)?.textContent ?? b.innerHTML.match(/
(?.*?)<\/div>/)?.groups.epxs; const latest_chapter = getLatestChapter(post_id, parseInt(epxs?.match(CHAPTER_REGEX)?.groups.chapter)); const { number, id } = getLastReadChapter(post_id); if (id) { const unreadChapters = latest_chapter - number; if (unreadChapters) { // reorder bookmark, link directly to last read chapter and slap an unread count badge. if (reorder) b.parentElement.prepend(b); $('a',b).href = '/?p=' + id; $('.limit',b).prepend(crel('div', { className: 'unread-badge', textContent: unreadChapters<100 ? unreadChapters : '💀', title: `${unreadChapters} unread chapter${unreadChapters>1?'s':''}` })) } else { // nothing new to read here. gray it out. b.style = 'filter: grayscale(70%);opacity:.9'; } } else { // we don't have data on that series. leave it alone. } }); } // text-mode /manga/ page. put badges at the end of each series title, and strike through what's already read. async function decorateText() { const links = await untilDOM(()=>$`.soralist a.series` && $$`.soralist a.series`); links.forEach(a => { const post_id = getSeriesId(a.rel, a.href); const latest_chapter = getLatestChapter(post_id); const { number, id } = getLastReadChapter(post_id); if (id) { const unreadChapters = latest_chapter - number; if (unreadChapters) { a.href = '/?p=' + id; a.append(crel('div', { className: 'unread-badge', textContent: unreadChapters<100 ? unreadChapters : '💀', title: `${unreadChapters} unread chapter${unreadChapters>1?'s':''}` })) } else { // nothing new to read here. gray it out. a.style = 'text-decoration: line-through;color: #777' } } }) } // page specific tweaks const chapterMatch = document.title.match(CHAPTER_REGEX); if (chapterMatch) { until(()=>unsafeWindow.post_id).then(() => { // We're on a chapter page. Save chapter number and id if greater than last saved chapter number. const chapter_number = parseInt(chapterMatch.groups.chapter ?? chapterMatch.groups.ch); const { post_id, chapter_id } = unsafeWindow; const { number = 0 } = getLastReadChapter(post_id); if (number { // We're on a bookmark page. Wait for them to load, then tweak them to point to last read chapter, and gray out the ones that are fully read so far. setTimeout(()=> { if (!$`#bookmark-pool [data-id]`) { // no data yet from bookmark API. show a fallback. $`#bookmark-pool`.innerHTML = GM_getValue(BOOKMARK_HTML, localStorage.bookmarkHTML ?? ''); // add a marker so we know this is just a cached rendering. $`#bookmark-pool [data-id]`.classList.add('cached'); // decorate what we have. decorateCards(); } }, 1000); // wait until we get bookmark markup from the server, not cached. await untilDOM("#bookmark-pool .bs:first-child [data-id]:not(.cached)"); // bookmarks' ajax API is flaky (/aggressively rate-limited) - mitigate. GM_setValue(BOOKMARK_HTML, $`#bookmark-pool`.innerHTML); decorateCards(); })(); else { // try generic decorations on any non-bookmark page decorateCards(false); decorateText(); } untilDOM(`#chapterlist`).then(() => { // Add a "Continue Reading" button on main series pages. const post_id = $`.bookmark`.dataset.id; const { number, id } = getLastReadChapter(post_id); // add a "Continue Reading" button for series we recognize if (id) { $`.lastend`.prepend(crel('div', { className: 'inepcx', style: 'width: 100%' }, crel('a', { href: '/?p=' + id }, crel('span', {}, 'Continue Reading'), crel('span', { className: 'epcur' }, 'Chapter ' + number)) )); } }); untilDOM(()=>$$$("//span[text()='Related Series' or text()='Similar Series']/../../..")[0]).then(related => { // Tweak footer content on any page that has them // 1. collapse related series. makeCollapsedFooter({label: 'Show Related Series', section: related}); related.style.display = 'none'; }); untilDOM("#comments").then(comments => { // 2. collapse comments. makeCollapsedFooter({label: 'Show Comments', section: comments}); }); // This page rotation thingy actually feels good now. async function rotatePage(clockwise) { if (rotating) return; rotating = true; const html = document.documentElement; html.style.overflow = "hidden"; const { scrollHeight, scrollTop, scrollWidth, scrollLeft, clientWidth, clientHeight, style } = html; const oldOriginY = parseInt(style.transformOrigin.split(" ")[1]); const from0 = (next) => () => { const originY = scrollTop + clientHeight/2; style.transformOrigin = `${clientWidth/2}px ${originY}px`; return next(true); }; const from90 = (next) => () => { const originY = scrollHeight - scrollLeft - clientWidth/2 - 1; // rounding error accumulation compensation, or something. style.transition = "initial"; style.transformOrigin = `${clientWidth/2}px ${originY}px`; style.transform=`rotate(90deg)`; scrollBy({top: originY - oldOriginY, behavior:"instant"}); scrollTo({left: 0, behavior: "instant"}); style.transition=''; return next(); }; const from180 = (next) => () => { const originY = scrollHeight - (scrollTop + clientHeight/2); style.transformOrigin = `${clientWidth/2}px ${originY}px`; scrollBy({top: 2*(originY - oldOriginY), behavior:"instant"}); return next(); }; const from270 = (next) => () => { const originY = scrollLeft + clientHeight/2; style.transition = "initial"; style.transformOrigin = `${clientWidth/2}px ${originY}px`; style.transform=`rotate(${ next == to0 ? "-90" : "270" }deg)`; scrollBy({top: originY - oldOriginY, behavior:"instant"}); scrollTo({left: 0, behavior: "instant"}); style.transition=''; return next(); }; const to0 = () => new Promise(next => { style.transform=""; html.addEventListener('transitionend', () => { style.overflow = ""; next(0); }, {once: true}); }); const to90 = () => new Promise(next => { style.transform=`rotate(90deg)`; html.addEventListener('transitionend', () => { style.transition='initial'; style.transform=`rotate(90deg) translate(0,${html.scrollWidth - scrollHeight}px)`; scrollTo({left: scrollHeight - html.scrollTop - clientWidth/2 - clientHeight/2, behavior:"instant"}); style.transition='' style.overflow = "auto hidden"; next(90); }, {once: true}); }); const to180 = () => new Promise(next => { style.transform="rotate(180deg)"; html.addEventListener('transitionend', () => { style.transition = "initial"; // we have to bring the transform origin in the middle of the page, or there will be misfits at the vertical edges (extra space or clipped page) style.transformOrigin = `${clientWidth/2}px ${scrollHeight/2}px`; scrollTo({top: scrollHeight - html.scrollTop - clientHeight, behavior:"instant"}); style.transition=''; style.overflow = "hidden auto"; next(180); }, {once: true}); }); const to270 = (from0) => new Promise(next => { style.transform=`rotate(${ from0 ? "-90" : "270" }deg)`; html.addEventListener('transitionend', () => { style.transition='initial'; style.transform=`rotate(-90deg) translate(0,${html.scrollTop}px)`; scrollTo({left: html.scrollTop, behavior:"instant"}); style.transition=''; style.overflow = "auto hidden"; next(270); }, {once: true}); }); const rotations = { 0: [from0(to270), from0(to90)], 90: [from90(to0), from90(to180)], 180: [from180(to90), from180(to270)], 270: [from270(to180), from270(to0)] } orientation = await rotations[orientation][~~clockwise](); rotating = false; }