Sleazy Fork is available in English.
Enhances Pawchive post pages with full-resolution images, smooth reader navigation, zoom, themes, and ZIP download support.
// ==UserScript== // @name Pawchive Comfy View // @version Kemono-Comfy-View-Port-1.0 // @description Enhances Pawchive post pages with full-resolution images, smooth reader navigation, zoom, themes, and ZIP download support. // @author L1Z4RD // @match https://pawchive.st/*/user/*/post/* // @match https://pawchive.pw/*/user/*/post/* // @grant GM_xmlhttpRequest // @connect file.pawchive.st // @connect file.pawchive.pw // @license MIT // @require https://unpkg.com/[email protected]/umd/index.js // @run-at document-end // @namespace http://tampermonkey.net/ // ==/UserScript== (function () { 'use strict'; if (window.__kcComfyViewInitialized) { return; } window.__kcComfyViewInitialized = true; const STORAGE_KEYS = { reader: 'kc_readerMode', fit: 'kc_fitToScreen', theme: 'kc_themeIndex' }; const THEMES = ['#353a45', '#dbdbdb', '#cdc5be', '#a19f8e']; const UI_IDS = { style: 'kc-ui-style', root: 'kc-ui-root' }; const TIMINGS = { collapseMs: 3000, clickMs: 220, longPressMs: 500, routePollMs: 500, refreshDebounceMs: 120, scrollCooldownMs: 280, downloadResetMs: 1800 }; const state = { readerMode: readBool(STORAGE_KEYS.reader, true), fitToScreen: readBool(STORAGE_KEYS.fit, true), themeIndex: readThemeIndex(), running: false, currentUrl: location.href, currentIndex: 0, listeners: [], mutationObserver: null, refreshTimer: 0, collapseTimer: 0, scrollUnlockTimer: 0, downloadResetTimer: 0, routePoller: 0, syncRaf: 0, scrollLocked: false, zoomedIndex: null, imageClickTimer: 0, lastEntrySignature: '', downloading: false, downloadController: null, loader: { queue: [], active: 0, maxConcurrent: 3, cache: new WeakMap(), scheduled: false, observer: null, aborters: new WeakMap() }, ui: null }; const ICONS = { up: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 19V5"></path><path d="M5 12l7-7 7 7"></path></svg>', reader: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M7 4.5h6.75A3.25 3.25 0 0 1 17 7.75V19a2.5 2.5 0 0 0-2.5-2.5H7z"></path></svg>', fit: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M9 4H4v5"></path><path d="M15 4h5v5"></path><path d="M9 20H4v-5"></path><path d="M15 20h5v-5"></path></svg>', theme: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="4.5"></circle><path d="M12 2.5v2.5"></path><path d="M12 19v2.5"></path><path d="M4.9 4.9l1.8 1.8"></path><path d="M17.3 17.3l1.8 1.8"></path><path d="M2.5 12H5"></path><path d="M19 12h2.5"></path><path d="M4.9 19.1l1.8-1.8"></path><path d="M17.3 6.7l1.8-1.8"></path></svg>', download: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 3v11"></path><path d="M7 10.5l5 5 5-5"></path><path d="M4 18.5v1a1.5 1.5 0 0 0 1.5 1.5h13A1.5 1.5 0 0 0 20 19.5v-1"></path></svg>' }; const utils = { isValidPage(url = location.href) { return /^https:\/\/pawchive\.(st|pw)\/[^/]+\/user\/[^/]+\/post\/[^/?#]+/i.test(url); }, getGalleryRoot() { return document.querySelector('.post__files'); }, getEntries() { return Array.from(document.querySelectorAll('.post__files a.fileThumb[href]')) .map((anchor, index) => { const img = anchor.querySelector('img'); return img ? { anchor, img, index, url: anchor.href } : null; }) .filter(Boolean); }, getImageUrls() { return utils.getEntries().map((entry) => entry.url); }, getMetaName() { const artist = document.querySelector('.post__user-name')?.textContent?.trim() || 'Unknown Artist'; const title = document.querySelector('.post__title')?.textContent?.trim() || 'Untitled Post'; return sanitizeFileName(`${artist} - ${title}`); }, getZipFileName(url, index) { const parsed = new URL(url, location.href); const rawName = decodeURIComponent( parsed.searchParams.get("f") || parsed.pathname.split("/").pop() || `image-${index + 1}` ); const sanitized = sanitizeFileName(rawName); const hasExtension = /\.[A-Za-z0-9]{2,5}$/.test(sanitized); const ext = hasExtension ? '' : `.${getExtension(url)}`; return `${String(index + 1).padStart(3, '0')}_${sanitized}${ext}`; }, isTypingTarget(target) { if (!target) { return false; } const element = target instanceof Element ? target : target.parentElement; if (!element) { return false; } const tagName = element.tagName; return element.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(tagName); } }; const imageLoader = { enqueue(entry) { const existing = state.loader.cache.get(entry.img); if (existing && (existing.status === "queued" || existing.status === "loading" || existing.status === "loaded")) { return; } state.loader.cache.set(entry.img, { status: "queued", retries: 0 }); state.loader.queue.push(entry); imageLoader.run(); }, run() { if (state.loader.scheduled) return; state.loader.scheduled = true; queueMicrotask(() => { state.loader.scheduled = false; while ( state.loader.active < state.loader.maxConcurrent && state.loader.queue.length ) { imageLoader.load(state.loader.queue.shift()); } }); }, async load(entry) { const { img, url } = entry; const info = state.loader.cache.get(img); if (!info) return; info.status = "loading"; state.loader.active++; try { await imageLoader.fetch(entry); info.status = "loaded"; } catch { if (info.retries < 2) { info.retries++; info.status = "queued"; state.loader.queue.push(entry); } else { info.status = "failed"; } } finally { state.loader.active--; imageLoader.run(); } }, observe(entry) { if (!state.loader.observer) { state.loader.observer = new IntersectionObserver( (entries) => { entries.forEach((item) => { if (!item.isIntersecting) return; const entry = item.target.__kcEntry; if (!entry) return; imageLoader.enqueue(entry); imageLoader.preloadNearby(entry.index); state.loader.observer.unobserve(item.target); }); }, { root: null, rootMargin: '1000px', threshold: 0.01 } ); } entry.anchor.__kcEntry = entry; state.loader.observer.observe(entry.anchor); }, preloadNearby(index) { const entries = utils.getEntries(); for (let i = 1; i <= 3; i++) { const next = entries[index + i]; if (!next) continue; const cached = state.loader.cache.get(next.img); if (!cached) { imageLoader.enqueue(next); } } }, async fetch(entry) { const { img, url } = entry; const controller = new AbortController(); state.loader.aborters.set(img, controller); return new Promise((resolve, reject) => { const preload = new Image(); let finished = false; preload.onload = async () => { if (controller.signal.aborted) return; finished = true; try { if (preload.decode) await preload.decode(); } catch {} img.classList.add("kc-loading"); requestAnimationFrame(() => { if (!document.contains(img)) return; if (controller.signal.aborted) return; img.src = preload.src; img.classList.remove('kc-loading'); cacheNaturalSize(img); resolve(); }); }; preload.onerror = () => { if (controller.signal.aborted) return; reject(); }; preload.src = url; controller.signal.addEventListener('abort', () => { if (finished) return; preload.src = ''; }); }); } }; const gallery = { upgrade() { const entries = utils.getEntries(); entries.forEach((entry) => { const { anchor, img, index } = entry; anchor.classList.add('kc-entry'); img.classList.add('kc-fullres'); img.removeAttribute('srcset'); img.removeAttribute('sizes'); img.removeAttribute('fetchpriority'); /* Keep the thumbnail. DO NOT replace img.src immediately anymore. */ img.loading = 'lazy'; img.decoding = 'async'; if (!img.dataset.kcLoadBound) { img.dataset.kcLoadBound = 'true'; img.addEventListener( 'load', onImageLoad, { passive: true } ); } if (!anchor.dataset.kcZoomBound) { anchor.dataset.kcZoomBound = 'true'; anchor.addEventListener( 'click', (event) => onImageClick(event, index) ); anchor.addEventListener( 'dblclick', (event) => onImageDoubleClick(event, index) ); } const cached = state.loader.cache.get(img); if (!cached || cached.status === 'idle' || cached.status === 'failed') { imageLoader.observe(entry); } }); syncZoomClasses(); } }; const navigation = { syncCurrentIndex() { const entries = utils.getEntries(); if (!entries.length) { state.currentIndex = 0; return; } const viewportMiddle = window.innerHeight / 2; let bestIndex = state.currentIndex; let bestScore = -Infinity; entries.forEach((entry, index) => { const rect = entry.anchor.getBoundingClientRect(); const visibleHeight = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)); const visibleRatio = visibleHeight / Math.max(rect.height, 1); const centerDistance = Math.abs(rect.top + rect.height / 2 - viewportMiddle); const score = visibleRatio * 1000 - centerDistance; if (score > bestScore) { bestScore = score; bestIndex = index; } }); state.currentIndex = bestIndex; }, scrollTo(index) { const entries = utils.getEntries(); const entry = entries[index]; if (!entry) { return; } state.currentIndex = index; const rect = entry.anchor.getBoundingClientRect(); const tallImage = rect.height > window.innerHeight * 0.92 && !state.fitToScreen; entry.anchor.scrollIntoView({ behavior: 'smooth', block: tallImage ? 'start' : 'center' }); }, move(step) { const entries = utils.getEntries(); if (!entries.length) { return; } navigation.syncCurrentIndex(); const nextIndex = clamp(state.currentIndex + step, 0, entries.length - 1); navigation.scrollTo(nextIndex); } }; const ui = { mount() { if (!document.body || state.ui?.root?.isConnected) { return; } ensureStyle(); const root = document.createElement('div'); root.id = UI_IDS.root; const navItem = createStackItem('nav', ICONS.up, 'Scroll to top'); const readerItem = createStackItem('reader', ICONS.reader, 'Reader mode'); const fitItem = createStackItem('fit', ICONS.fit, 'Fit mode'); const themeItem = createStackItem('theme', ICONS.theme, 'Theme'); const downloadItem = createStackItem('download', ICONS.download, 'Download ZIP'); root.append(navItem.item, readerItem.item, fitItem.item, themeItem.item, downloadItem.item); document.body.appendChild(root); const readerToggles = createToggleGroup([ { label: 'On', active: state.readerMode, onSelect() { updateReaderMode(true); ui.closeAll(); } }, { label: 'Off', active: !state.readerMode, onSelect() { updateReaderMode(false); ui.closeAll(); } } ]); const fitToggles = createToggleGroup([ { label: 'Fit', active: state.fitToScreen, onSelect() { updateFitMode(true); ui.closeAll(); } }, { label: 'Natural', active: !state.fitToScreen, onSelect() { updateFitMode(false); ui.closeAll(); } } ]); const themePanel = document.createElement('div'); themePanel.className = 'kc-swatches'; const themeButtons = THEMES.map((color, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'kc-swatch'; button.style.backgroundColor = color; button.setAttribute('aria-label', `Theme ${index + 1}`); button.addEventListener('click', () => { updateTheme(index); ui.closeAll(); }); themePanel.appendChild(button); return button; }); const downloadPanel = document.createElement('div'); downloadPanel.className = 'kc-progress'; const downloadLabel = document.createElement('div'); downloadLabel.className = 'kc-progress__label'; downloadLabel.textContent = 'Download ZIP'; const downloadTrack = document.createElement('div'); downloadTrack.className = 'kc-progress__track'; const downloadFill = document.createElement('div'); downloadFill.className = 'kc-progress__fill'; downloadTrack.appendChild(downloadFill); downloadPanel.append(downloadLabel, downloadTrack); readerItem.panel.appendChild(readerToggles.root); fitItem.panel.appendChild(fitToggles.root); themeItem.panel.appendChild(themePanel); downloadItem.panel.appendChild(downloadPanel); wireSimplePanelToggle(readerItem, 'reader'); wireSimplePanelToggle(fitItem, 'fit'); wireSimplePanelToggle(themeItem, 'theme'); downloadItem.button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); ui.open('download'); if (!state.downloading) { downloader.zipAll(); } else { ui.armIdleCollapse(); } }); let navPressTimer = 0; let navLongPressFired = false; const clearNavTimer = () => { if (navPressTimer) { clearTimeout(navPressTimer); navPressTimer = 0; } }; navItem.button.addEventListener('pointerdown', (event) => { event.preventDefault(); event.stopPropagation(); navLongPressFired = false; clearNavTimer(); navPressTimer = window.setTimeout(() => { navLongPressFired = true; window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }); navPressTimer = 0; }, TIMINGS.longPressMs); }); const releaseNav = (event) => { event.preventDefault(); event.stopPropagation(); if (navPressTimer) { clearNavTimer(); if (!navLongPressFired) { window.scrollTo({ top: 0, behavior: 'smooth' }); } } }; navItem.button.addEventListener('pointerup', releaseNav); navItem.button.addEventListener('pointerleave', clearNavTimer); navItem.button.addEventListener('pointercancel', clearNavTimer); state.ui = { root, items: { nav: navItem, reader: readerItem, fit: fitItem, theme: themeItem, download: downloadItem }, readerButtons: readerToggles.buttons, fitButtons: fitToggles.buttons, themeButtons, downloadLabel, downloadFill }; ui.sync(); ui.resetDownload(); }, destroy() { clearTimeout(state.collapseTimer); state.collapseTimer = 0; if (state.ui?.root?.isConnected) { state.ui.root.remove(); } state.ui = null; }, open(name) { if (!state.ui) { return; } Object.entries(state.ui.items).forEach(([key, item]) => { item.item.classList.toggle('is-open', key === name); }); if (name === 'download' || state.downloading) { clearTimeout(state.collapseTimer); state.collapseTimer = 0; return; } ui.armIdleCollapse(); }, closeAll(options = {}) { if (!state.ui) { return; } Object.entries(state.ui.items).forEach(([key, item]) => { if (options.keepDownload && key === 'download' && state.downloading) { item.item.classList.add('is-open'); return; } item.item.classList.remove('is-open'); }); }, armIdleCollapse() { clearTimeout(state.collapseTimer); state.collapseTimer = 0; if (state.downloading) { return; } state.collapseTimer = window.setTimeout(() => { ui.closeAll(); }, TIMINGS.collapseMs); }, sync() { if (!state.ui) { return; } state.ui.readerButtons[0].classList.toggle('is-active', state.readerMode); state.ui.readerButtons[1].classList.toggle('is-active', !state.readerMode); state.ui.fitButtons[0].classList.toggle('is-active', state.fitToScreen); state.ui.fitButtons[1].classList.toggle('is-active', !state.fitToScreen); state.ui.themeButtons.forEach((button, index) => { button.classList.toggle('is-active', index === state.themeIndex); }); }, updateProgress(done, total, text, status = 'idle') { if (!state.ui) { return; } const downloadItem = state.ui.items.download.item; const percent = total > 0 ? Math.min(100, (done / total) * 100) : 0; state.ui.downloadLabel.textContent = text; state.ui.downloadFill.style.width = `${percent}%`; downloadItem.classList.toggle('is-complete', status === 'complete'); downloadItem.classList.toggle('is-error', status === 'error'); if (status === 'loading') { downloadItem.classList.remove('is-complete', 'is-error'); } }, resetDownload() { clearTimeout(state.downloadResetTimer); state.downloadResetTimer = 0; ui.updateProgress(0, 1, 'Download ZIP', 'idle'); ui.closeAll(); }, finishDownload(label, status) { ui.updateProgress(1, 1, label, status); state.downloadResetTimer = window.setTimeout(() => { ui.resetDownload(); }, TIMINGS.downloadResetMs); } }; const downloader = { async zipAll() { const urls = utils.getImageUrls(); if (!urls.length || typeof fflate === 'undefined') { ui.finishDownload('No images found', 'error'); return; } if (state.downloading) { return; } state.downloading = true; state.downloadController = new AbortController(); ui.open('download'); ui.updateProgress(0, urls.length, `0 / ${urls.length}`, 'loading'); const zipFiles = {}; const workerCount = Math.min(getDownloadConcurrency(), urls.length); let cursor = 0; let completed = 0; let failures = 0; const worker = async () => { while (cursor < urls.length) { const index = cursor; cursor += 1; try { const data = await new Promise((resolve,reject)=>{GM_xmlhttpRequest({method:'GET',url:urls[index],responseType:'arraybuffer',onload:r=>{if(r.status!==200)return reject(new Error('HTTP '+r.status)); if(!r.response||r.response.byteLength<100)return reject(new Error('Invalid response')); resolve(r.response);},onerror:reject,onabort:()=>reject(new DOMException('Aborted','AbortError'))});}); zipFiles[utils.getZipFileName(urls[index], index)] = new Uint8Array(data); } catch (error) { if (error?.name === 'AbortError') { throw error; } failures += 1; } finally { completed += 1; ui.updateProgress(completed, urls.length, `${completed} / ${urls.length}`, 'loading'); } } }; try { await Promise.all(Array.from({ length: workerCount }, () => worker())); ui.updateProgress(urls.length, urls.length, 'Packing ZIP...', 'loading'); console.time('KC generate'); const zipped = fflate.zipSync(zipFiles, { level: 0 }); const archive = new Blob( [zipped], { type: "application/zip" } ); console.timeEnd('KC generate'); const downloadLink = document.createElement('a'); downloadLink.href = URL.createObjectURL(archive); downloadLink.download = `${utils.getMetaName()}.zip`; document.body.appendChild(downloadLink); downloadLink.click(); downloadLink.remove(); window.setTimeout(() => URL.revokeObjectURL(downloadLink.href), 30000); if (failures > 0) { ui.finishDownload(`Done with ${failures} failed`, 'error'); } else { ui.finishDownload(`Downloaded ${urls.length} images`, 'complete'); } } catch (error) { if (error?.name === 'AbortError') { ui.finishDownload('Download canceled', 'error'); } else { ui.finishDownload('Download failed', 'error'); } } finally { state.downloading = false; state.downloadController = null; } } }; const lifecycle = { start() { if (state.running || !utils.isValidPage() || !document.body) { return; } ensureStyle(); state.running = true; state.currentIndex = 0; state.lastEntrySignature = ''; applyModeClasses(); ui.mount(); bindRuntimeListeners(); observeMutations(); refreshPage(true); }, stop() { if (!state.running) { return; } state.running = false; state.currentIndex = 0; state.lastEntrySignature = ''; state.scrollLocked = false; state.zoomedIndex = null; state.downloading = false; clearTimeout(state.refreshTimer); clearTimeout(state.collapseTimer); clearTimeout(state.scrollUnlockTimer); clearTimeout(state.downloadResetTimer); clearTimeout(state.imageClickTimer); state.imageClickTimer = 0; if (state.syncRaf) { cancelAnimationFrame(state.syncRaf); state.syncRaf = 0; } if (state.mutationObserver) { state.mutationObserver.disconnect(); state.mutationObserver = null; } if (state.downloadController) { state.downloadController.abort(); state.downloadController = null; state.loader.aborters = new WeakMap(); state.loader.queue.length = 0; state.loader.active = 0; } if (state.loader.observer) { state.loader.observer.disconnect(); state.loader.observer = null; } state.listeners.forEach((remove) => remove()); state.listeners.length = 0; ui.destroy(); if (document.body) { document.body.classList.remove('kc-post-active', 'kc-reader-mode', 'kc-fit-mode'); } document.documentElement.style.removeProperty('--kc-theme-bg'); }, syncWithRoute(force = false) { const urlChanged = location.href !== state.currentUrl; if (!force && !urlChanged) { return; } state.currentUrl = location.href; if (!utils.isValidPage()) { lifecycle.stop(); return; } lifecycle.stop(); window.setTimeout(() => lifecycle.start(), 0); } }; function readBool(key, fallback) { const rawValue = localStorage.getItem(key); if (rawValue === null) { return fallback; } return rawValue === 'true'; } function readThemeIndex() { const saved = Number(localStorage.getItem(STORAGE_KEYS.theme)); return Number.isInteger(saved) && saved >= 0 && saved < THEMES.length ? saved : 0; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function sanitizeFileName(text) { return text.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim() || 'untitled'; } function getExtension(url) { const parsed = new URL(url, location.href); const lastSegment = parsed.pathname.split('/').pop() || ''; const match = lastSegment.match(/\.([A-Za-z0-9]{2,5})$/); return match ? match[1] : 'jpg'; } function getDownloadConcurrency() { const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; if (connection?.saveData) { return 1; } const effectiveType = String(connection?.effectiveType || '').toLowerCase(); const downlink = Number(connection?.downlink || 0); if (effectiveType.includes('2g') || (downlink && downlink < 1.2)) { return 1; } if (effectiveType === '3g' || (downlink && downlink < 4)) { return 2; } return 3; } function isImageZoomEnabled() { return state.running && state.readerMode && state.fitToScreen; } function isZoomModeActive() { return isImageZoomEnabled() && state.zoomedIndex !== null; } function getEntryByIndex(index) { return utils.getEntries()[index] || null; } function syncZoomClasses() { utils.getEntries().forEach((entry, index) => { entry.anchor.classList.toggle('is-zoomed', state.zoomedIndex === index && isImageZoomEnabled()); }); } function enterZoomMode(index) { if (!isImageZoomEnabled()) { return; } state.zoomedIndex = index; syncZoomClasses(); const entry = getEntryByIndex(index); if (entry) { requestAnimationFrame(() => { entry.anchor.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); } } function exitZoomMode(options = {}) { if (state.zoomedIndex === null) { return; } const previousIndex = state.zoomedIndex; state.zoomedIndex = null; syncZoomClasses(); if (options.recenter) { const entry = getEntryByIndex(previousIndex); if (entry) { requestAnimationFrame(() => { entry.anchor.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); } } } function onImageClick(event, index) { if (!isImageZoomEnabled()) { return; } event.preventDefault(); event.stopPropagation(); clearTimeout(state.imageClickTimer); state.imageClickTimer = 0; if (state.zoomedIndex === index) { return; } state.imageClickTimer = window.setTimeout(() => { state.imageClickTimer = 0; enterZoomMode(index); }, TIMINGS.clickMs); } function onImageDoubleClick(event, index) { if (!isImageZoomEnabled()) { return; } event.preventDefault(); event.stopPropagation(); clearTimeout(state.imageClickTimer); state.imageClickTimer = 0; if (state.zoomedIndex === index) { exitZoomMode({ recenter: true }); } } function cacheNaturalSize(img) { if (!img?.naturalWidth) { return; } img.style.setProperty('--kc-natural-width', `${img.naturalWidth}px`); } function onImageLoad(event) { cacheNaturalSize(event.currentTarget); } function updateReaderMode(value) { state.readerMode = Boolean(value); localStorage.setItem(STORAGE_KEYS.reader, String(state.readerMode)); if (!isImageZoomEnabled()) { exitZoomMode(); } applyModeClasses(); ui.sync(); } function updateFitMode(value) { state.fitToScreen = Boolean(value); localStorage.setItem(STORAGE_KEYS.fit, String(state.fitToScreen)); if (!isImageZoomEnabled()) { exitZoomMode(); } applyModeClasses(); ui.sync(); } function updateTheme(index) { state.themeIndex = clamp(index, 0, THEMES.length - 1); localStorage.setItem(STORAGE_KEYS.theme, String(state.themeIndex)); applyTheme(); ui.sync(); } function cycleTheme() { updateTheme((state.themeIndex + 1) % THEMES.length); } function ensureStyle() { if (document.getElementById(UI_IDS.style)) { return; } const style = document.createElement('style'); style.id = UI_IDS.style; style.textContent = ` :root { --kc-theme-bg: ${THEMES[state.themeIndex]}; --kc-ui-surface: rgba(245, 241, 232, 0.96); --kc-ui-stroke: rgba(0, 0, 0, 0.08); --kc-ui-text: #2d3137; --kc-ui-track: rgba(0, 0, 0, 0.12); --kc-ui-fill: #303843; --kc-ui-fill-done: #2d8a4f; --kc-ui-fill-error: #c47a2c; } body.kc-post-active { background: var(--kc-theme-bg) !important; transition: background-color 180ms ease; } body.kc-post-active .post__files { display: flex; flex-direction: column; align-items: center; gap: clamp(10px, 2vh, 18px); padding: 10px 0 104px; } body.kc-post-active .post__files > * { width: min(100%, 1800px); } body.kc-post-active .post__files a.fileThumb { display: flex; justify-content: center; align-items: center; width: 100%; scroll-margin-top: 10px; scroll-margin-bottom: 10px; } body.kc-post-active .post__files img.kc-fullres { display: block; width: auto; height: auto; max-width: min(calc(100vw - 32px), 1800px); border-radius: 10px; background: rgba(0, 0, 0, 0.07); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); image-rendering: auto; transition: opacity .25s ease, max-height .18s ease, width .18s ease, max-width .18s ease, border-radius .18s ease; } body.kc-post-active .post__files img.kc-fullres.kc-loading { opacity: .45; } body.kc-post-active.kc-fit-mode .post__files img.kc-fullres { max-height: calc(100vh - 1cm); object-fit: contain; cursor: zoom-in; } body.kc-post-active:not(.kc-fit-mode) .post__files img.kc-fullres { width: min(var(--kc-natural-width, calc(100vw - 32px)), calc(100vw - 32px)); max-width: none; max-height: none; object-fit: initial; } body.kc-post-active.kc-fit-mode .post__files a.kc-entry.is-zoomed { align-items: flex-start; } body.kc-post-active.kc-fit-mode .post__files a.kc-entry.is-zoomed img.kc-fullres { width: min(var(--kc-natural-width, calc(100vw - 24px)), calc(100vw - 24px)); max-width: none; max-height: none; object-fit: initial; cursor: zoom-out; } #${UI_IDS.root} { position: fixed; right: 20px; bottom: 20px; z-index: 2147483646; display: flex; flex-direction: column; gap: 12px; pointer-events: none; font-family: "Segoe UI", sans-serif; user-select: none; } .kc-stack-item { position: relative; display: flex; align-items: center; justify-content: flex-end; min-height: 48px; pointer-events: auto; } .kc-panel { position: absolute; right: 56px; top: 50%; transform: translate3d(12px, -50%, 0); display: flex; align-items: center; gap: 10px; height: 46px; padding: 0; max-width: 0; overflow: hidden; white-space: nowrap; opacity: 0; pointer-events: none; border: 1px solid var(--kc-ui-stroke); border-radius: 999px; background: var(--kc-ui-surface); backdrop-filter: blur(14px); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18); transition: max-width 220ms ease, opacity 180ms ease, transform 220ms ease, padding 180ms ease; } .kc-stack-item.is-open .kc-panel { max-width: 340px; padding: 0 12px 0 16px; opacity: 1; transform: translate3d(0, -50%, 0); pointer-events: auto; } .kc-btn { width: 46px; height: 46px; border: 1px solid var(--kc-ui-stroke); border-radius: 50%; background: var(--kc-ui-surface); color: var(--kc-ui-text); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18); transition: transform 150ms ease, background-color 150ms ease, color 150ms ease; } .kc-btn:hover { transform: scale(1.04); } .kc-btn svg { display: block; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; flex: none; } .kc-segmented { display: flex; align-items: center; gap: 8px; } .kc-chip { min-width: 74px; height: 32px; padding: 0 12px; border: 0; border-radius: 999px; background: transparent; color: rgba(45, 49, 55, 0.72); cursor: pointer; font-size: 13px; font-weight: 600; transition: background-color 150ms ease, color 150ms ease; } .kc-chip.is-active { background: #303843; color: #f7f8fa; } .kc-swatches { display: flex; align-items: center; gap: 10px; } .kc-swatch { width: 26px; height: 26px; border: 2px solid transparent; border-radius: 50%; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); transition: transform 150ms ease, border-color 150ms ease; } .kc-swatch.is-active { border-color: #303843; transform: scale(1.08); } .kc-progress { width: 180px; display: flex; flex-direction: column; gap: 6px; } .kc-progress__label { font-size: 12px; font-weight: 600; color: var(--kc-ui-text); text-align: left; } .kc-progress__track { width: 100%; height: 6px; border-radius: 999px; overflow: hidden; background: var(--kc-ui-track); } .kc-progress__fill { width: 0; height: 100%; border-radius: inherit; background: var(--kc-ui-fill); transition: width 180ms linear, background-color 180ms ease; } .kc-stack-item[data-item="download"].is-complete .kc-progress__fill { background: var(--kc-ui-fill-done); } .kc-stack-item[data-item="download"].is-error .kc-progress__fill { background: var(--kc-ui-fill-error); } `; document.head.appendChild(style); } function createStackItem(name, icon, label) { const item = document.createElement('div'); item.className = 'kc-stack-item'; item.dataset.item = name; const panel = document.createElement('div'); panel.className = 'kc-panel'; const button = document.createElement('button'); button.type = 'button'; button.className = 'kc-btn'; button.innerHTML = icon; button.setAttribute('aria-label', label); item.append(panel, button); return { item, panel, button }; } function createToggleGroup(items) { const root = document.createElement('div'); root.className = 'kc-segmented'; const buttons = items.map((item) => { const button = document.createElement('button'); button.type = 'button'; button.className = `kc-chip${item.active ? ' is-active' : ''}`; button.textContent = item.label; button.addEventListener('click', item.onSelect); root.appendChild(button); return button; }); return { root, buttons }; } function wireSimplePanelToggle(entry, name) { entry.button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const isOpen = entry.item.classList.contains('is-open'); if (isOpen) { ui.closeAll(); } else { ui.open(name); } }); } function applyTheme() { document.documentElement.style.setProperty('--kc-theme-bg', THEMES[state.themeIndex]); } function applyModeClasses() { if (!document.body) { return; } document.body.classList.toggle('kc-post-active', state.running); document.body.classList.toggle('kc-reader-mode', state.running && state.readerMode); document.body.classList.toggle('kc-fit-mode', state.running && state.fitToScreen); applyTheme(); syncZoomClasses(); } function bindRuntimeListeners() { const addManagedListener = (target, type, handler, options) => { target.addEventListener(type, handler, options); state.listeners.push(() => target.removeEventListener(type, handler, options)); }; const handleWheel = (event) => { if (!state.running || !state.readerMode || utils.isTypingTarget(event.target)) { return; } if (isZoomModeActive()) { return; } if (Math.abs(event.deltaY) < 8) { return; } // Fit Mode keeps the current one-wheel-per-image behavior. if (state.fitMode) { event.preventDefault(); if (state.scrollLocked) { return; } state.scrollLocked = true; navigation.move(event.deltaY > 0 ? 1 : -1); clearTimeout(state.scrollUnlockTimer); state.scrollUnlockTimer = window.setTimeout(() => { state.scrollLocked = false; }, TIMINGS.scrollCooldownMs); return; } // Natural Mode const current = utils.getEntries()[state.currentIndex]; if (!current) return; const rect = current.anchor.getBoundingClientRect(); const threshold = 40; if (event.deltaY > 0) { // Not at the bottom yet? Let the browser scroll normally. if (rect.bottom > window.innerHeight + threshold) { return; } } else { // Not at the top yet? Let the browser scroll normally. if (rect.top < -threshold) { return; } } event.preventDefault(); if (state.scrollLocked) return; state.scrollLocked = true; navigation.move(event.deltaY > 0 ? 1 : -1); clearTimeout(state.scrollUnlockTimer); state.scrollUnlockTimer = window.setTimeout(() => { state.scrollLocked = false; }, TIMINGS.scrollCooldownMs); }; const handleKeydown = (event) => { if (!state.running || event.altKey || event.ctrlKey || event.metaKey || utils.isTypingTarget(document.activeElement)) { return; } const key = event.key.toLowerCase(); if (key === 'r') { event.preventDefault(); updateReaderMode(!state.readerMode); ui.armIdleCollapse(); return; } if (key === 'f') { event.preventDefault(); updateFitMode(!state.fitToScreen); ui.armIdleCollapse(); return; } if (key === 't') { event.preventDefault(); cycleTheme(); ui.armIdleCollapse(); return; } if (!state.readerMode) { return; } if (isZoomModeActive()) { return; } if (event.code === 'Space' || key === 'arrowdown' || key === 'pagedown') { event.preventDefault(); navigation.move(1); return; } if (key === 'arrowup' || key === 'pageup') { event.preventDefault(); navigation.move(-1); } }; const syncIndexFromScroll = () => { if (!state.running) { return; } if (state.syncRaf) { cancelAnimationFrame(state.syncRaf); } state.syncRaf = requestAnimationFrame(() => { state.syncRaf = 0; navigation.syncCurrentIndex(); if (state.zoomedIndex !== null && state.currentIndex !== state.zoomedIndex) { exitZoomMode(); } }); }; const handlePointerDown = (event) => { if (!state.ui?.root) { return; } if (state.ui.root.contains(event.target)) { ui.armIdleCollapse(); return; } ui.closeAll({ keepDownload: true }); }; addManagedListener(window, 'wheel', handleWheel, { passive: false }); addManagedListener(window, 'keydown', handleKeydown); addManagedListener(window, 'scroll', syncIndexFromScroll, { passive: true }); addManagedListener(window, 'resize', syncIndexFromScroll, { passive: true }); addManagedListener(document, 'pointerdown', handlePointerDown, true); } function observeMutations() { if (state.mutationObserver || !document.body) { return; } state.mutationObserver = new MutationObserver(() => { clearTimeout(state.refreshTimer); state.refreshTimer = window.setTimeout(() => { refreshPage(); }, TIMINGS.refreshDebounceMs); }); state.mutationObserver.observe(document.body, { childList: true, subtree: true }); } function refreshPage(force = false) { if (!state.running) { return; } gallery.upgrade(); imageLoader.run(); applyModeClasses(); ui.mount(); ui.sync(); if (state.zoomedIndex !== null && !getEntryByIndex(state.zoomedIndex)) { exitZoomMode(); } const signature = utils.getImageUrls().join('|'); if (force || signature !== state.lastEntrySignature) { state.lastEntrySignature = signature; navigation.syncCurrentIndex(); } } function installRouteWatcher() { const notify = () => { window.setTimeout(() => { lifecycle.syncWithRoute(true); }, 0); }; ['pushState', 'replaceState'].forEach((methodName) => { const original = history[methodName]; history[methodName] = function wrappedHistoryMethod(...args) { const result = original.apply(this, args); notify(); return result; }; }); window.addEventListener('popstate', notify, { passive: true }); window.addEventListener('hashchange', notify, { passive: true }); state.routePoller = window.setInterval(() => { lifecycle.syncWithRoute(); }, TIMINGS.routePollMs); } function boot() { installRouteWatcher(); lifecycle.syncWithRoute(true); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();