// Vertical-scroll (webtoon) renderer for comics: all pages stacked in one // scrolling column, lazily loaded with an IntersectionObserver and unloaded // when far from the reading position (keeping placeholder geometry stable // via aspect-ratio so the scrollbar never jumps). export class Webtoon extends HTMLElement { #root = this.attachShadow({ mode: "closed" }); #book; #pages = []; #index = -1; #firstNav = true; #connected = false; #scrollRaf = null; constructor() { super(); const sheet = new CSSStyleSheet(); this.#root.adoptedStyleSheets = [sheet]; sheet.replaceSync(` :host { display: block; width: 100%; height: 100%; overflow-y: auto; overscroll-behavior: contain; } .page { max-width: 900px; margin: 0 auto; position: relative; } .page img { display: block; width: 100%; /* brightness/contrast/invert driven by the host page (CSS custom properties inherit into shadow DOM) */ filter: var(--fx-filter, none); } `); this.addEventListener("scroll", () => this.#onScroll(), { passive: true, }); } open(book) { this.#book = book; const sections = book.sections ?? []; // Placeholder height until the image loads and sets the real // aspect-ratio; oversized so early scrolling doesn't skip pages. const minH = Math.round((window.innerHeight || 1000) * 1.4); const frag = document.createDocumentFragment(); this.#pages = sections.map((section, i) => { const el = document.createElement("div"); el.className = "page"; el.dataset.index = i; el.style.minHeight = `${minH}px`; frag.append(el); return { el, section, loaded: false, img: null, loading: false }; }); this.#root.append(frag); // Loading is driven from scroll position (#updateIndex), not an // IntersectionObserver: IO with a shadow-host root proved unreliable // in Chromium and would leave pages permanently blank. if (this.#connected) this.#updateIndex(); } connectedCallback() { this.#connected = true; if (this.#pages.length) this.#updateIndex(); } async #loadPage(i) { const p = this.#pages[i]; if (!p || p.loaded || p.loading) return; p.loading = true; try { const url = await p.section.load?.(); if (!url) return; const html = await (await fetch(url)).text(); const m = html.match(/src="(blob:[^"]+)"/); if (!m) return; const img = new Image(); img.decoding = "async"; await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = m[1]; }); // Fix the geometry so later unloading never shifts scroll height. p.el.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`; p.el.style.minHeight = "0"; p.el.append(img); p.img = img; p.loaded = true; } catch (e) { console.warn("webtoon: failed to load page", i + 1, e); } finally { p.loading = false; } } // Load a window around the reading position (a few pages of lookahead). #loadAround(idx) { const start = Math.max(0, idx - 2); const end = Math.min(this.#pages.length - 1, idx + 4); for (let i = start; i <= end; i++) this.#loadPage(i); } // Free far-away pages (images + their blob URLs) while keeping the // aspect-ratio placeholder, so the column height stays stable. #unloadFar(current) { for (let i = 0; i < this.#pages.length; i++) { const p = this.#pages[i]; if (!p.loaded || !p.img) continue; if (Math.abs(i - current) > 12) { p.img.remove(); p.img = null; p.loaded = false; p.section.unload?.(); } } } #onScroll() { if (this.#scrollRaf) return; this.#scrollRaf = requestAnimationFrame(() => { this.#scrollRaf = null; this.#updateIndex(); }); } #updateIndex() { if (!this.#pages.length) return; const hostRect = this.getBoundingClientRect(); const center = hostRect.top + hostRect.height / 2; let idx = this.#index < 0 ? 0 : this.#index; const n = this.#pages.length; while (idx < n - 1) { const r = this.#pages[idx].el.getBoundingClientRect(); if (center < r.bottom - 1) break; idx++; } while (idx > 0) { const r = this.#pages[idx].el.getBoundingClientRect(); if (center > r.top + 1) break; idx--; } const r = this.#pages[idx].el.getBoundingClientRect(); const fraction = Math.min( 1, Math.max(0, (center - r.top) / (r.height || 1)), ); if (idx !== this.#index) { this.#index = idx; this.#unloadFar(idx); } this.#loadAround(idx); this.#report(fraction); } #report(fraction = 0, reason = "scroll") { this.dispatchEvent( new CustomEvent("relocate", { detail: { reason, range: null, index: Math.max(0, this.#index), fraction, size: 1, }, }), ); } get index() { return Math.max(0, this.#index); } // Renderer interface (mirrors fixed-layout): goTo receives a thenable // resolving to { index }. async goTo(target) { const resolved = await target; const p = this.#pages[resolved?.index]; if (!p) return; this.#firstNav = false; p.el.scrollIntoView({ block: "start" }); this.#index = resolved.index; requestAnimationFrame(() => this.#updateIndex()); } // Screen-height paging, like dedicated webtoon readers. The first call is // view.init()'s fresh-start probe — a no-op that just reports location. async next() { if (this.#firstNav) { this.#firstNav = false; this.scrollTo(0, 0); this.#index = 0; this.#updateIndex(); return true; } this.scrollBy({ top: this.clientHeight * 0.88, behavior: "smooth" }); return true; } async prev() { this.scrollBy({ top: -this.clientHeight * 0.88, behavior: "smooth" }); return true; } goLeft() { return this.prev(); } goRight() { return this.next(); } getContents() { return []; } destroy() { } } customElements.define("foliate-webtoon", Webtoon);