Kemono Comfy View

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        Kemono Comfy View
// @name:ja     ケモノ快適ビュー
// @name:zh-CN  Kemono 舒适浏览
// @version     2.1
// @description        Enhances Kemono post pages with full-resolution images, smooth reader navigation, zoom, themes, and ZIP download support.
// @description:ja     Kemono の投稿ページを改善し、フル解像度画像による快適な閲覧とスムーズなナビゲーションを提供します。ズーム・テーマ切替・ZIPダウンロード対応。
// @description:zh-CN  优化 Kemono 帖子页面浏览体验,支持原图显示、阅读模式、缩放、主题切换与 ZIP 下载。
// @author      L1Z4RD
// @match       https://kemono.cr/*/user/*/post/*
// @grant       none
// @license      MIT
// @require     https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.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,
        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:\/\/kemono\.cr\/[^/]+\/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.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 gallery = {
        upgrade() {
            const entries = utils.getEntries();
            entries.forEach((entry) => {
                const { anchor, img, index, url } = entry;

                anchor.classList.add('kc-entry');
                img.classList.add('kc-fullres');
                img.removeAttribute('srcset');
                img.removeAttribute('sizes');
                img.loading = 'eager';
                img.removeAttribute('fetchpriority');

                if (img.src !== url) {
                    img.src = url;
                }

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

                if (img.complete) {
                    cacheNaturalSize(img);
                }
            });

            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 JSZip === '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 zip = new JSZip();
            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 response = await fetch(urls[index], {
                            credentials: 'same-origin',
                            signal: state.downloadController.signal
                        });
                        if (!response.ok) {
                            throw new Error(`HTTP ${response.status}`);
                        }
                        const blob = await response.blob();
                        zip.file(utils.getZipFileName(urls[index], index), blob);
                    } 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');

                const archive = await zip.generateAsync({ type: 'blob' }, (metadata) => {
                    ui.updateProgress(100, 100, `Packing ZIP... ${Math.round(metadata.percent)}%`, 'loading');
                });

                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.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: max-height 180ms ease, width 180ms ease, max-width 180ms ease, border-radius 180ms ease;
            }

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

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