☰

Pawchive Fit Viewer

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

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

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