// ==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://gf.qytechs.cn/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js
// @grant GM_setValue
// @grant GM_getValue
// ==/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`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#913fe2" viewBox="0 0 14 14" width="32" height="32">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 1 7.3 6.3a1 1 180 0 0 0 1.4L13 13M7 1 1.3 6.3a1 1 180 0 0 0 1.4L7 13"/>
</svg>`;
const CURSOR_NEXT = 'data:image/svg+xml;base64,'+btoa`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#913fe2" viewBox="0 0 14 14" width="32" height="32">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 13 5.7-5.3a1 1 0 0 0 0-1.4L1 1m6 12 5.7-5.3a1 1 0 0 0 0-1.4L7 1"/>
</svg>`;
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 (?<chapter>\d+)\b|\bch.(?<ch>\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 class="epxs">(?<epxs>.*?)<\/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<chapter_number) {
setLastReadChapter(post_id, chapter_id, chapter_number);
}
});
}
if (location.pathname.match(/^\/bookmarks?\/$/)) (async () => {
// 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;
}