Original-resolution images/GIFs/videos auto-fitted to the screen, smooth scrolling, 4 background colors, click-to-open gallery.
// ==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();
})();