☰

Pawchive Comfy View

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

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name        Pawchive Comfy View
// @version     Kemono-Comfy-View-Port-1.1
// @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(),
            aborters: new Map(), // Changed from WeakMap to Map for iterable cancellation
            scheduled: false,
            observer: null
        },

        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;
            return element.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(element.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 } = 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 entryData = item.target.__kcEntry;
                        if (!entryData) return;

                        imageLoader.enqueue(entryData);
                        imageLoader.preloadNearby(entryData.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;
                if (!state.loader.cache.get(next.img)) {
                    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) {
                            // Safari/WebKit protection timeout
                            await Promise.race([
                                preload.decode(),
                                new Promise(res => setTimeout(res, 2000))
                            ]);
                        }
                    } catch {}

                    img.classList.add("kc-loading");

                    requestAnimationFrame(() => {
                        state.loader.aborters.delete(img);
                        if (!document.contains(img) || controller.signal.aborted) return;

                        img.src = preload.src;
                        img.classList.remove('kc-loading');
                        cacheNaturalSize(img);
                        resolve();
                    });
                };

                preload.onerror = () => {
                    state.loader.aborters.delete(img);
                    if (controller.signal.aborted) return;
                    reject();
                };

                preload.src = url;
                controller.signal.addEventListener('abort', () => {
                    if (finished) return;
                    preload.src = '';
                    reject(new DOMException('Aborted', 'AbortError'));
                });
            });
        }
    };

    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');

                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') && !img.dataset.kcObserved) {
                    img.dataset.kcObserved = 'true';
                    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();
            const signal = state.downloadController.signal;

            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) {
                    if (signal.aborted) {
                        throw new DOMException('Aborted', 'AbortError');
                    }
                    const index = cursor;
                    cursor += 1;

                    try {
                        const data = await new Promise((resolve, reject) => {
                            const req = 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'))
                            });

                            signal.addEventListener('abort', () => {
                                if (req.abort) req.abort();
                            });
                        });

                        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()));
                if (signal.aborted) throw new DOMException('Aborted', 'AbortError');

                ui.updateProgress(urls.length, urls.length, 'Packing ZIP...', 'loading');

                const zipped = fflate.zipSync(zipFiles, { level: 0 });
                const archive = new Blob([zipped], { type: "application/zip" });

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

            // Abort and clear image fetchers properly
            for (const controller of state.loader.aborters.values()) {
                controller.abort();
            }
            state.loader.aborters.clear();
            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: One wheel scroll movement per image step
            if (state.fitToScreen) {
                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: Scroll boundary detection
            const current = utils.getEntries()[state.currentIndex];
            if (!current) return;

            const rect = current.anchor.getBoundingClientRect();
            const threshold = 40;

            if (event.deltaY > 0) {
                if (rect.bottom > window.innerHeight + threshold) return;
            } else {
                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 === 'escape' && isZoomModeActive()) {
                event.preventDefault();
                exitZoomMode({ recenter: true });
                return;
            }

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