Enhances Kemono post pages with full-resolution images, smooth reader navigation, zoom, themes, and ZIP download support.
// ==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();
}
})();