Greasy Fork

Usability Tweaks for Manga sites.

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.

目前为 2023-08-03 提交的版本。查看 最新版本

// ==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.8
// @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
// ==/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.

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 */
* {
  user-select: none;
  -webkit-user-drag: none;
}
body.drag {
  cursor: grabbing !important;
}
/* support mouse swiping to navigate between chapters */
#readerarea {
  position: relative;
}
.swipe {
  position: fixed;
  top: calc( 50% - 20px );
  right: calc( 50% - 100px );
  pointer-events: none;
  opacity: 0;
  padding: 4px 30px;
  border-radius: 40px;
  background: #913fe2;
  color: #fff;
  font-size: 26px;
  font-weight: bold;
  white-space: nowrap;
}

/* 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 }
`);

// 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' && !e.repeat && ({
  ArrowLeft: prev,
  ArrowRight: next,
  KeyA: prev,
  KeyD: next,
  KeyL: toggleLandscape,
}[e.code]?.()), true);

// inertial drag scrolling and swipe navigation
let landscape = false;
let [ delta, drag, dragged, navigating, navPos ] = [0, false, false, false, 0];
let previousTouch;
const eventListeners = {
  // wheel event, used to handle landscape scrolling
  wheel: e => landscape && scrollBy(-e.wheelDeltaY, 0),
  // mouse dragging/swiping
  mousedown: () => {
    [ delta, drag, dragged, navigating, navPos ] = [0, true, false, false, 0];
  },
  mousemove: e => {
    if (drag) {
      if (!navigating) {
        if (landscape) {
          scrollBy(delta=-e.movementX, 0);
        } else {
          scrollBy(0, delta=-e.movementY);
        }
      }
      if (!landscape) {
        navPos+=e.movementX;
        if (navigating) { // show some resistance before swiping.
          const readerArea = $`#readerarea`, swipeLabels = $$`.swipe`;
          readerArea.style.transform = `translate(${navPos}px)`;
          swipeLabels[~~(navPos<0)].style.opacity = '1';
          swipeLabels[~~(navPos>0)].removeAttribute('style');
        }
      }
      if (Math.abs(delta)>3) {
        dragged = true;
        document.body.classList.add('drag');
        $`#readerarea`.style.transform = "translate(0px)";
      } else if (Math.abs(navPos)>100) {
        navigating = true;
        document.body.classList.add('drag');
      }
    }
  },
  mouseup: () => {
    if (drag) {
      drag=false;
      rAF((_, next) => Math.abs(delta*=0.98)>1 && next(landscape?scrollBy(delta,0):scrollBy(0, delta)));
    }
    if (dragged) {
      dragged = false;
      document.body.classList.remove('drag');
      const preventClick = e => {
        e.preventDefault();
        e.stopPropagation();
        removeEventListener('click', preventClick, true);
      };
      addEventListener('click', preventClick, true);
    }
    if (navigating) {
      navigating = false;
      document.body.classList.remove('drag');
      const width = $`#readerarea`.clientWidth;
      if (navPos > width/4) prev();
      else if (navPos < -width/4) next();
      else {
        $`#readerarea`.style = "transition: all .5s; transform: translate(0px)";
        $$`.swipe`.forEach(swipe=>swipe.removeAttribute('style'));
        setTimeout(()=> $`#readerarea`.style = "", 600);
      }
    }
  },
  // adapters for touch events (mobile etc.)
  touchstart: () => {
    eventListeners.mousedown();
  },
  touchmove: e => {
    if (e.touches.length === 1) {
      const [touch] = e.touches;
      e.movementX = previousTouch ? touch.pageX - previousTouch.pageX : 0;
      e.movementY = 0; // don't fight native mobile scrolling
      previousTouch = touch;
      eventListeners.mousemove(e);
    }
  },
  touchend: () => {
    previousTouch = null;
    eventListeners.mouseup();
  }
};
events(eventListeners);

// add swipe indicators
untilDOM("body").then(body=>body.prepend(crel("div", {
    className: "swipe swipe-left",
    innerHTML: `<i class="fas fa-angle-left"></i>&nbsp;Previous Chapter`
  }),
  crel("div", {
    className: "swipe swipe-right",
    innerHTML: ` Next Chapter&nbsp;<i class="fas fa-angle-right"></i>`
  })
));

// 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);
  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).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) {
  // 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});
});

// Add ability to render as landscape, since long form strips have to known to switch to it.
function toggleLandscape() {
  const enabled = document.body.style.transform !== "";
  if (enabled) {
    const offset = document.documentElement.scrollLeft;
    document.body.style.transform="";
    document.body.style.overflowY="";
    scrollTo(0, offset);
  } else {
    document.body.style.transform=`rotate(270deg) translate(0,${document.body.scrollHeight/2-innerHeight/2}px)`;
    document.body.style.overflowY="hidden";
    scrollTo(document.documentElement.scrollTop,document.body.scrollHeight/2-innerHeight/2);
  }
  landscape = !enabled;
}