☰

Pawchive Fit Viewer

Original-resolution images/GIFs/videos auto-fitted to the screen, smooth scrolling, 4 background colors, click-to-open gallery.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Pawchive Fit Viewer
// @name:ru      Pawchive Fit Viewer
// @namespace    local.pawchive.fitviewer
// @version      1.0.1
// @description  Original-resolution images/GIFs/videos auto-fitted to the screen, smooth scrolling, 4 background colors, click-to-open gallery.
// @description:ru Оригинальное разрешение картинок/GIF/видео с автоподгонкой под экран, плавный скролл, 4 цвета фона, галерея по клику.
// @license      MIT
// @match        https://pawchive.st/*
// @match        https://pawchive.pw/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    if (window.__kcFitViewer) return;
    window.__kcFitViewer = true;

    /* ------------------------------------------------------------------ */
    /* Настройки                                                           */
    /* ------------------------------------------------------------------ */
    const CFG = {
        smoothTau: 110,        // мс. Меньше = резче, больше = мягче и «тягучее»
        wheelMult: 1,          // множитель шага колеса
        keyStep: 110,          // пикс. за нажатие стрелки
        pageStepRatio: 0.88,   // доля экрана для PageUp/PageDown/Space
        galleryWheelCooldown: 280
    };

    const THEMES = [
        { bg: '#353a45', fg: '#e6e8ec' },
        { bg: '#dbdbdb', fg: '#1f2328' },
        { bg: '#cdc5be', fg: '#231f1c' },
        { bg: '#a19f8e', fg: '#1c1b17' }
    ];
    const THEME_KEY = 'kcfv_theme';

    /* ------------------------------------------------------------------ */
    /* Утилиты                                                             */
    /* ------------------------------------------------------------------ */
    const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
    const isPost = () => /\/user\/[^/]+\/post\/[^/?#]+/.test(location.pathname);

    function isTyping(target) {
        const el = target instanceof Element ? target : target && target.parentElement;
        if (!el) return false;
        return el.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(el.tagName);
    }

    function debounce(fn, ms) {
        let t = 0;
        return () => {
            clearTimeout(t);
            t = setTimeout(fn, ms);
        };
    }

    function canScrollInside(el, dy) {
        for (let n = el; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
            const style = getComputedStyle(n);
            if (!/(auto|scroll)/.test(style.overflowY)) continue;
            if (n.scrollHeight <= n.clientHeight + 1) continue;
            if (dy < 0 && n.scrollTop > 0) return true;
            if (dy > 0 && n.scrollTop + n.clientHeight < n.scrollHeight - 1) return true;
        }
        return false;
    }

    /* ------------------------------------------------------------------ */
    /* Стили                                                               */
    /* ------------------------------------------------------------------ */
    const style = document.createElement('style');
    style.id = 'kcfv-style';
    style.textContent = `
:root { --kc-bg: ${THEMES[0].bg}; --kc-fg: ${THEMES[0].fg}; }

html { scroll-behavior: auto !important; }
html.kc-lb-open { overflow: hidden !important; }

/* ---------- Тема (только на странице поста) ---------- */
body.kc-post {
  background: var(--kc-bg) !important;
  color: var(--kc-fg) !important;
  transition: background-color 180ms ease;
}
body.kc-post .content-wrapper,
body.kc-post main,
body.kc-post #main,
body.kc-post .site-section,
body.kc-post .post-view {
  background: transparent !important;
}
body.kc-post :is(
  .post__title, .post__title span, .post__user-name, .post__published,
  .post__info, .post__content, .post__content :not(a), .post__comments,
  .site-section__subheading, .comment__body, .comment__name
) { color: var(--kc-fg) !important; }

/* ---------- Галерея на странице ---------- */
body.kc-post .post__files {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}
body.kc-post .post__files > * {
  display: flex;
  justify-content: center;
  width: 100%;
  margin: 0;
}
body.kc-post .post__files a.fileThumb {
  display: flex;
  justify-content: center;
  max-width: 100%;
}
body.kc-post .post__files img {
  display: block;
  width: auto !important;
  height: auto !important;
  max-width: 100% !important;
  max-height: calc(100vh - 24px) !important;
  object-fit: contain;
  border-radius: 8px;
  cursor: zoom-in;
}
body.kc-post .post__files img.kc-img:not(.kc-ready) {
  min-height: 50vh;
  min-width: min(60vw, 640px);
  background: rgba(128, 128, 128, 0.14);
}

/* ---------- Видео ---------- */
body.kc-post video {
  display: block;
  max-width: 100%;
  max-height: calc(100vh - 24px);
  margin-left: auto;
  margin-right: auto;
  object-fit: contain;
  border-radius: 8px;
}
body.kc-post .fluid_video_wrapper {
  max-width: 100%;
  margin-left: auto;
  margin-right: auto;
}

/* ---------- Переключатель цвета ---------- */
#kc-ui {
  position: fixed;
  right: 16px;
  bottom: 16px;
  z-index: 2147482000;
  display: none;
  gap: 8px;
  padding: 8px 10px;
  border-radius: 999px;
  background: rgba(24, 26, 31, 0.72);
  backdrop-filter: blur(10px);
  opacity: 0.4;
  transition: opacity 160ms ease;
}
body.kc-post #kc-ui { display: flex; }
#kc-ui:hover, #kc-ui:focus-within { opacity: 1; }
#kc-ui button {
  width: 22px;
  height: 22px;
  padding: 0;
  border-radius: 50%;
  border: 2px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
  cursor: pointer;
}
#kc-ui button.on { border-color: #fff; }
#kc-ui button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }

/* ---------- Просмотрщик ---------- */
#kc-lb {
  position: fixed;
  inset: 0;
  z-index: 2147483000;
  display: none;
  background: rgba(8, 9, 12, 0.94);
}
#kc-lb.open { display: block; }
#kc-lb .kc-stage {
  position: absolute;
  inset: 0;
  display: flex;
  overflow: auto;
}
#kc-lb .kc-stage img {
  flex: none;
  display: block;
  margin: auto;
  width: auto;
  height: auto;
  max-width: 100vw;
  max-height: 100vh;
  object-fit: contain;
  cursor: zoom-in;
  user-select: none;
  -webkit-user-drag: none;
  transition: opacity 120ms ease;
}
#kc-lb .kc-stage.zoomed img {
  max-width: none;
  max-height: none;
  cursor: zoom-out;
}
#kc-lb .kc-btn {
  position: fixed;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  color: #fff;
  background: rgba(255, 255, 255, 0.1);
  cursor: pointer;
  transition: background-color 140ms ease, opacity 140ms ease;
}
#kc-lb .kc-btn:hover { background: rgba(255, 255, 255, 0.22); }
#kc-lb .kc-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
#kc-lb .kc-btn:disabled { opacity: 0.2; cursor: default; }
#kc-lb .kc-btn svg {
  width: 26px; height: 26px; fill: none; stroke: currentColor;
  stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round;
}
#kc-lb .kc-prev, #kc-lb .kc-next {
  top: 50%; width: 52px; height: 76px; margin-top: -38px; border-radius: 12px;
}
#kc-lb .kc-prev { left: 14px; }
#kc-lb .kc-next { right: 14px; }
#kc-lb .kc-close {
  top: 14px; right: 14px; width: 44px; height: 44px; border-radius: 50%;
}
#kc-lb .kc-count {
  position: fixed;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  padding: 4px 12px;
  border-radius: 999px;
  color: #fff;
  font: 600 13px/1.6 system-ui, sans-serif;
  background: rgba(255, 255, 255, 0.12);
  pointer-events: none;
}
#kc-lb.single .kc-prev, #kc-lb.single .kc-next, #kc-lb.single .kc-count { display: none; }
`;
    document.head.appendChild(style);

    /* ------------------------------------------------------------------ */
    /* Плавный скролл (с нуля)                                             */
    /* ------------------------------------------------------------------ */
    const smooth = (() => {
        let target = 0;
        let pos = 0;
        let last = 0;
        let prevT = 0;
        let raf = 0;

        const scroller = () => document.scrollingElement || document.documentElement;
        const maxScroll = () => Math.max(0, scroller().scrollHeight - window.innerHeight);

        function frame(t) {
            // Страницу сдвинули извне (полоса прокрутки, якорь и т.п.) — уступаем
            if (Math.abs(scroller().scrollTop - last) > 2) {
                raf = 0;
                return;
            }

            const dt = Math.min(50, t - prevT || 16);
            prevT = t;
            target = clamp(target, 0, maxScroll());
            pos += (target - pos) * (1 - Math.exp(-dt / CFG.smoothTau));

            if (Math.abs(target - pos) < 0.4) {
                pos = target;
                scroller().scrollTop = pos;
                last = scroller().scrollTop;
                raf = 0;
                return;
            }

            scroller().scrollTop = pos;
            last = scroller().scrollTop;
            raf = requestAnimationFrame(frame);
        }

        function start() {
            if (raf) return;
            pos = target = scroller().scrollTop;
            last = pos;
            prevT = performance.now();
            raf = requestAnimationFrame(frame);
        }

        return {
            by(delta) {
                start();
                target = clamp(target + delta, 0, maxScroll());
            },
            to(y) {
                start();
                target = clamp(y, 0, maxScroll());
            }
        };
    })();

    function onWheel(event) {
        if (event.defaultPrevented || event.ctrlKey || event.metaKey || lb.open) return;
        if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
        if (!event.deltaY || canScrollInside(event.target, event.deltaY)) return;

        let delta = event.deltaY;
        if (event.deltaMode === 1) delta *= 32;
        else if (event.deltaMode === 2) delta *= window.innerHeight;

        event.preventDefault();
        smooth.by(delta * CFG.wheelMult);
    }

    /* ------------------------------------------------------------------ */
    /* Тема                                                                */
    /* ------------------------------------------------------------------ */
    let themeIndex = 0;
    try {
        const saved = Number(localStorage.getItem(THEME_KEY));
        if (Number.isInteger(saved) && saved >= 0 && saved < THEMES.length) themeIndex = saved;
    } catch (e) { /* localStorage может быть недоступен */ }

    const ui = document.createElement('div');
    ui.id = 'kc-ui';
    const swatches = THEMES.map((theme, index) => {
        const button = document.createElement('button');
        button.type = 'button';
        button.style.backgroundColor = theme.bg;
        button.setAttribute('aria-label', `Цвет фона ${index + 1}`);
        button.addEventListener('click', () => setTheme(index));
        ui.appendChild(button);
        return button;
    });
    document.body.appendChild(ui);

    function setTheme(index) {
        themeIndex = clamp(index, 0, THEMES.length - 1);
        const theme = THEMES[themeIndex];
        document.documentElement.style.setProperty('--kc-bg', theme.bg);
        document.documentElement.style.setProperty('--kc-fg', theme.fg);
        swatches.forEach((button, i) => button.classList.toggle('on', i === themeIndex));
        try { localStorage.setItem(THEME_KEY, String(themeIndex)); } catch (e) { /* ignore */ }
    }
    setTheme(themeIndex);

    /* ------------------------------------------------------------------ */
    /* Картинки на странице                                                */
    /* ------------------------------------------------------------------ */
    function upgradeMedia() {
        document.querySelectorAll('.post__files img').forEach((img) => {
            if (img.dataset.kcDone) return;
            const anchor = img.closest('a');
            const url = anchor && anchor.href;
            if (!url) return;

            img.dataset.kcDone = '1';
            img.classList.add('kc-img');
            img.removeAttribute('srcset');
            img.removeAttribute('sizes');
            img.removeAttribute('fetchpriority');
            img.loading = 'lazy';
            img.decoding = 'async';

            let tries = 0;
            img.addEventListener('load', () => img.classList.add('kc-ready'));
            img.addEventListener('error', () => {
                if (tries++ >= 3) return;
                setTimeout(() => {
                    const src = img.src;
                    img.removeAttribute('src');
                    img.src = src;
                }, 1500 * tries);
            });

            if (img.src !== url) img.src = url;
            if (img.complete && img.naturalWidth) img.classList.add('kc-ready');
        });
    }

    // Другие скрипты (например Kemer Enhance) могут чистить <head> — возвращаем стили
    function ensureStyle() {
        if (!style.isConnected) (document.head || document.documentElement).appendChild(style);
    }

    function refresh() {
        ensureStyle();
        if (!document.body) return;
        const on = isPost();
        document.body.classList.toggle('kc-post', on);
        if (on) upgradeMedia();
    }

    /* ------------------------------------------------------------------ */
    /* Просмотрщик (галерея)                                               */
    /* ------------------------------------------------------------------ */
    const ICON_PREV = '<svg viewBox="0 0 24 24"><path d="M15 5l-7 7 7 7"></path></svg>';
    const ICON_NEXT = '<svg viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"></path></svg>';
    const ICON_CLOSE = '<svg viewBox="0 0 24 24"><path d="M6 6l12 12"></path><path d="M18 6L6 18"></path></svg>';

    const lb = {
        el: null, stage: null, img: null, count: null, prev: null, next: null,
        items: [], index: 0, open: false, zoomed: false, lastWheel: 0, touchX: null
    };

    function collectItems() {
        const seen = new Set();
        const items = [];
        document.querySelectorAll('.post__files img').forEach((img) => {
            const anchor = img.closest('a');
            const url = (anchor && anchor.href) || img.currentSrc || img.src;
            if (!url || seen.has(url)) return;
            seen.add(url);
            items.push({ img, url });
        });
        return items;
    }

    function buildViewer() {
        if (lb.el) return;

        const el = document.createElement('div');
        el.id = 'kc-lb';
        el.innerHTML = `
<div class="kc-stage"><img alt="" draggable="false"></div>
<button type="button" class="kc-btn kc-prev" aria-label="Предыдущее">${ICON_PREV}</button>
<button type="button" class="kc-btn kc-next" aria-label="Следующее">${ICON_NEXT}</button>
<button type="button" class="kc-btn kc-close" aria-label="Закрыть">${ICON_CLOSE}</button>
<div class="kc-count"></div>`;
        document.body.appendChild(el);

        lb.el = el;
        lb.stage = el.querySelector('.kc-stage');
        lb.img = el.querySelector('.kc-stage img');
        lb.count = el.querySelector('.kc-count');
        lb.prev = el.querySelector('.kc-prev');
        lb.next = el.querySelector('.kc-next');

        lb.prev.addEventListener('click', () => step(-1));
        lb.next.addEventListener('click', () => step(1));
        el.querySelector('.kc-close').addEventListener('click', closeViewer);

        lb.stage.addEventListener('click', (event) => {
            if (event.target === lb.stage) closeViewer();
            else if (event.target === lb.img) toggleZoom();
        });

        lb.stage.addEventListener('wheel', (event) => {
            if (lb.zoomed) return; // в увеличенном виде колесо скроллит картинку
            event.preventDefault();
            const now = performance.now();
            if (now - lb.lastWheel < CFG.galleryWheelCooldown) return;
            const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
            if (Math.abs(delta) < 4) return;
            lb.lastWheel = now;
            step(delta > 0 ? 1 : -1);
        }, { passive: false });

        lb.stage.addEventListener('touchstart', (event) => {
            lb.touchX = event.touches.length === 1 ? event.touches[0].clientX : null;
        }, { passive: true });
        lb.stage.addEventListener('touchend', (event) => {
            if (lb.touchX === null || lb.zoomed) return;
            const dx = event.changedTouches[0].clientX - lb.touchX;
            lb.touchX = null;
            if (Math.abs(dx) > 50) step(dx < 0 ? 1 : -1);
        }, { passive: true });
    }

    function toggleZoom() {
        lb.zoomed = !lb.zoomed;
        lb.stage.classList.toggle('zoomed', lb.zoomed);
        if (!lb.zoomed) lb.stage.scrollTo(0, 0);
    }

    function show(index) {
        lb.index = index;
        const item = lb.items[index];
        lb.zoomed = false;
        lb.stage.classList.remove('zoomed');
        lb.stage.scrollTo(0, 0);

        lb.img.style.opacity = '0.35';
        lb.img.onload = () => { lb.img.style.opacity = '1'; };
        lb.img.onerror = () => { lb.img.style.opacity = '1'; };
        lb.img.src = item.url;
        if (lb.img.complete && lb.img.naturalWidth) lb.img.style.opacity = '1';

        lb.count.textContent = `${index + 1} / ${lb.items.length}`;
        lb.prev.disabled = index === 0;
        lb.next.disabled = index === lb.items.length - 1;

        [index + 1, index - 1, index + 2].forEach((j) => {
            const near = lb.items[j];
            if (near) new Image().src = near.url;
        });
    }

    function step(delta) {
        const next = clamp(lb.index + delta, 0, lb.items.length - 1);
        if (next !== lb.index) show(next);
    }

    function openViewer(startImg) {
        buildViewer();
        lb.items = collectItems();
        if (!lb.items.length) return;

        let index = lb.items.findIndex((item) => item.img === startImg);
        if (index < 0) {
            const anchor = startImg.closest('a');
            const url = anchor && anchor.href;
            index = Math.max(0, lb.items.findIndex((item) => item.url === url));
        }

        lb.open = true;
        lb.el.classList.toggle('single', lb.items.length < 2);
        lb.el.classList.add('open');
        document.documentElement.classList.add('kc-lb-open');
        show(index);
    }

    function closeViewer() {
        if (!lb.open) return;
        lb.open = false;
        lb.el.classList.remove('open');
        document.documentElement.classList.remove('kc-lb-open');
        lb.img.removeAttribute('src');

        // Возвращаем страницу к последней просмотренной картинке
        const item = lb.items[lb.index];
        if (item && item.img.isConnected) item.img.scrollIntoView({ block: 'center' });
    }

    function onPageClick(event) {
        if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
        if (!isPost()) return;
        const img = event.target instanceof Element && event.target.closest('.post__files img');
        if (!img) return;

        event.preventDefault();
        event.stopImmediatePropagation();
        openViewer(img);
    }

    /* ------------------------------------------------------------------ */
    /* Клавиатура                                                          */
    /* ------------------------------------------------------------------ */
    function onKeydown(event) {
        if (event.altKey || event.ctrlKey || event.metaKey) return;
        const key = event.key;

        if (lb.open) {
            let handled = true;
            if (key === 'Escape') closeViewer();
            else if (key === 'ArrowRight') step(1);
            else if (key === 'ArrowLeft') step(-1);
            else if (!lb.zoomed && (key === 'ArrowDown' || key === 'PageDown')) step(1);
            else if (!lb.zoomed && (key === 'ArrowUp' || key === 'PageUp')) step(-1);
            else if (!lb.zoomed && key === ' ') step(event.shiftKey ? -1 : 1);
            else if (key === 'Home') show(0);
            else if (key === 'End') show(lb.items.length - 1);
            else handled = false;

            if (handled) {
                event.preventDefault();
                event.stopImmediatePropagation();
            }
            return;
        }

        if (isTyping(event.target)) return;
        if (event.target instanceof Element && event.target.closest('video, .fluid_video_wrapper')) return;

        const page = window.innerHeight * CFG.pageStepRatio;
        switch (key) {
            case 'ArrowDown': smooth.by(CFG.keyStep); break;
            case 'ArrowUp': smooth.by(-CFG.keyStep); break;
            case 'PageDown': smooth.by(page); break;
            case 'PageUp': smooth.by(-page); break;
            case ' ': smooth.by(event.shiftKey ? -page : page); break;
            case 'Home': smooth.to(0); break;
            case 'End': smooth.to(Number.MAX_SAFE_INTEGER); break;
            case 't':
            case 'T':
                if (isPost()) setTheme((themeIndex + 1) % THEMES.length);
                return;
            default: return;
        }
        event.preventDefault();
        event.stopImmediatePropagation();
    }

    /* ------------------------------------------------------------------ */
    /* Запуск                                                              */
    /* ------------------------------------------------------------------ */
    window.addEventListener('wheel', onWheel, { passive: false });
    window.addEventListener('keydown', onKeydown, true);
    document.addEventListener('click', onPageClick, true);

    const scheduleRefresh = debounce(refresh, 100);
    new MutationObserver(scheduleRefresh).observe(document.body, { childList: true, subtree: true });
    if (document.head) {
        new MutationObserver(ensureStyle).observe(document.head, { childList: true });
    }
    window.addEventListener('popstate', scheduleRefresh);
    ['pushState', 'replaceState'].forEach((name) => {
        const original = history[name];
        history[name] = function (...args) {
            const result = original.apply(this, args);
            scheduleRefresh();
            return result;
        };
    });

    refresh();
})();