Usability Tweaks for Asura Scans

Keyboard navigation, inertial drag scrolling, chapter preloading and chapter tracking

当前为 2023-07-16 提交的版本,查看 最新版本

// ==UserScript==
// @name        Usability Tweaks for Asura Scans
// @namespace   Itsnotlupus Industries
// @match       https://www.asurascans.com/*
// @match       https://asura.gg/*
// @noframes
// @version     1.4
// @author      Itsnotlupus
// @license     MIT
// @description Keyboard navigation, inertial drag scrolling, chapter preloading and chapter tracking
// @require     https://greasyfork.org/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js
// ==/UserScript==

/* jshint esversion:11 */

// fixConsole();

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;
}

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

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

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

// inertial drag scrolling
let [ delta, drag, dragged ] = [0, false, false];
events({
  mousedown() {
    [ delta, drag, dragged ] = [0, true, false];
  },
  mousemove(e) {
    if (drag) {
      scrollBy(0, delta=-e.movementY);
      if (Math.abs(delta)>3) {
        dragged = true;
        document.body.classList.add('drag');
      }
    }
  },
  mouseup(e) {
    if (drag) {
      drag=false;
      rAF((_, next) => Math.abs(delta*=0.95)>1 && next(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);
    }
  }
});

// don't be shy about loading an entire chapter
$$`img[loading="lazy"]`.forEach(img => img.loading="eager");

// retry loading broken images
const imgBackoff = new Map();
const imgNextRetry = new Map();
const retryImage = 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);
  }
}
observeDOM(() => {
  [...document.images].filter(img=>img.complete && !img.naturalHeight).forEach(retryImage);
});

// and prefetch the next chapter's images for even less waiting.
const nextURL = $`.ch-next-btn`?.href;
if (nextURL) fetchHTML(nextURL).then(d => [...d.images].forEach(img => prefetch(img.src)));


// have bookmarks track the last chapter you read
// XXX If we used GM APIs to store this instead (and stored localStorage.bookmark there too), TamperMonkey would be able to sync across browsers and devices.
const LAST_READ_CHAPTER_KEY = "lastReadChapter";
const SERIES_ID_HREF_MAP = "seriesIdHrefMap";
const SERIES_ID_LATEST_MAP = "seriesIdLatestMap";
const lastReadChapters = JSON.parse(localStorage.getItem(LAST_READ_CHAPTER_KEY) ?? "{}");
const seriesIdHrefMap = JSON.parse(localStorage.getItem(SERIES_ID_HREF_MAP) ?? "{}");
const seriesIdLatestMap = JSON.parse(localStorage.getItem(SERIES_ID_LATEST_MAP) ?? "{}");

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
  };
  localStorage.setItem(LAST_READ_CHAPTER_KEY, JSON.stringify(lastReadChapters));
}

function getSeriesId(post_id, href) {
  if (post_id) {
    seriesIdHrefMap[href] = post_id;
    localStorage.setItem(SERIES_ID_HREF_MAP, JSON.stringify(seriesIdHrefMap));
  } else {
    post_id = seriesIdHrefMap[href];
  }
  return post_id;
}

function getLatestChapter(post_id, chapter) {
  if (chapter) {
    seriesIdLatestMap[post_id] = chapter;
    localStorage.setItem(SERIES_ID_LATEST_MAP, JSON.stringify(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/i;
function decorateCards(reorder = true) {
  $$$("//div[contains(@class, 'listupd')]//div[contains(@class, 'bsx')]/..").reverse().forEach(b => {
    const post_id =  getSeriesId(b.firstElementChild.dataset.id, $('a', b).href);
    const latest_chapter = getLatestChapter(post_id, +$('.epxs',b).textContent.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.
function decorateText() {
  $$`.soralist a.series`.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 = +chapterMatch.groups.chapter;
  const { post_id, chapter_id } = window;
  const { number = 0 } = getLastReadChapter(post_id);
  if (number<chapter_number) {
    setLastReadChapter(post_id, chapter_id, chapter_number);
  }
}

if (location.pathname == '/bookmark/') (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 = 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.
  localStorage.bookmarkHTML = $`#bookmark-pool`.innerHTML;
  decorateCards();
})(); else {
  // try generic decorations on any non-bookmark page
  decorateCards(false);
  decorateText();
}

if ($`#chapterlist`) {
  // 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))
    ));
  }
}

// Tweak footer content on any page that has them
// 1. collapse related series.
const related = $$$("//span[text()='Related Series']/../../..")[0];
if (related) {
  makeCollapsedFooter({label: 'Show Related Series', section: related});
  related.style.display = 'none';
}
// 2. collapse comments.
const comments = $`#comments`;
if (comments) makeCollapsedFooter({label: 'Show Comments', section: comments});