Kemono Comfy View

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
    }
})();