Pawchive Comfy View

Enhances Pawchive post pages with full-resolution images, smooth reader navigation, zoom, themes, and ZIP download support.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==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();
    }
})();