Force-loads every image in a Webtoons chapter on page open instead of lazy-loading on scroll. Handles SPA navigation between chapters.
// ==UserScript==
// @name Webtoons Chapter Preloader
// @namespace https://github.com/hervad/webtoons-chapter-preloader
// @version 1.0.0
// @description Force-loads every image in a Webtoons chapter on page open instead of lazy-loading on scroll. Handles SPA navigation between chapters.
// @author hervad
// @match https://www.webtoons.com/*/viewer*
// @icon https://www.webtoons.com/favicon.ico
// @run-at document-idle
// @grant none
// @noframes
// @license MIT
// @homepageURL https://github.com/hervad/webtoons-chapter-preloader
// @supportURL https://github.com/hervad/webtoons-chapter-preloader/issues
// ==/UserScript==
(function () {
'use strict';
const IMG_SELECTOR = '#_imageList img';
const STATUS_ID = '__wt_preloader_status';
/* ---------- core: copy data-url -> src ---------- */
function preloadImage(img) {
const realUrl = img.dataset.url;
if (!realUrl) return false; // nothing to do
if (img.src === realUrl) return false; // already pointing at the real one
img.src = realUrl;
img.loading = 'eager';
img.decoding = 'async';
return true;
}
function preloadAll() {
const imgs = document.querySelectorAll(IMG_SELECTOR);
let started = 0;
imgs.forEach(img => { if (preloadImage(img)) started++; });
return { total: imgs.length, started };
}
/* ---------- tiny progress bubble ---------- */
function ensureStatusEl() {
let el = document.getElementById(STATUS_ID);
if (el) return el;
el = document.createElement('div');
el.id = STATUS_ID;
Object.assign(el.style, {
position: 'fixed',
bottom: '16px',
right: '16px',
zIndex: '2147483647',
padding: '8px 12px',
background: 'rgba(0, 0, 0, 0.78)',
color: '#fff',
font: '12px/1.4 system-ui, -apple-system, sans-serif',
borderRadius: '6px',
pointerEvents: 'none',
transition: 'opacity .3s',
opacity: '0',
});
document.body.appendChild(el);
return el;
}
function showStatus(text) {
const el = ensureStatusEl();
el.textContent = text;
el.style.opacity = '1';
}
function fadeStatus(delay = 1500) {
const el = document.getElementById(STATUS_ID);
if (!el) return;
setTimeout(() => { el.style.opacity = '0'; }, delay);
}
function trackProgress() {
const imgs = [...document.querySelectorAll(IMG_SELECTOR)];
if (!imgs.length) return;
const update = () => {
const loaded = imgs.filter(i => i.complete && i.naturalWidth > 0).length;
if (loaded >= imgs.length) {
showStatus(`✓ Preloaded ${imgs.length} images`);
fadeStatus();
} else {
showStatus(`Preloading ${loaded} / ${imgs.length}…`);
}
};
update();
for (const img of imgs) {
if (img.dataset.__wtTracked) continue;
img.dataset.__wtTracked = '1';
img.addEventListener('load', update, { once: true });
img.addEventListener('error', update, { once: true });
}
}
/* ---------- entry points ---------- */
function run() {
const { total } = preloadAll();
if (total > 0) trackProgress();
}
run();
/* ---------- handle SPA chapter changes & late-inserted imgs ---------- */
// Webtoons swaps the imageList in-place when navigating chapters. Filter
// mutations to ones that actually add images so we don't rerun on every
// unrelated DOM tick (comments, like counts, etc.).
let pending;
const scheduleRun = () => {
clearTimeout(pending);
pending = setTimeout(run, 100);
};
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.tagName === 'IMG' || node.querySelector?.('img')) {
scheduleRun();
return;
}
}
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
})();