Created
April 24, 2026 16:56
-
-
Save POMXARK/87e8cb73567de873fff868cd8a98d15e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Rutracker full size images embed | |
| // @namespace Violentmonkey Scripts | |
| // @match https://rutracker.org/forum/viewtopic.php* | |
| // @grant GM_xmlhttpRequest | |
| // @connect * | |
| // @version 3.1 | |
| // @author szq2 | |
| // @run-at document-idle | |
| // @license MIT | |
| // @description Rutracker full size images embed fastpic.org vfl.ru | |
| // ==/UserScript== | |
| (async function () { | |
| 'use strict'; | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| /* ── Inline replaced images ── */ | |
| .fp-post-img { | |
| max-width: 100%; height: auto; display: block; | |
| margin: 8px 0; cursor: zoom-in; width: 100%; | |
| box-shadow: 0 0 0 1px #c3cbd1, 0 2px 6px rgba(0,0,0,0.12); | |
| } | |
| /* ── Shimmer placeholder while signed URL is fetching ── */ | |
| .fp-post-img.fp-loading { | |
| min-height: 220px; width: 100%; | |
| background: linear-gradient(90deg, #1e2a38 25%, #2a3d52 50%, #1e2a38 75%); | |
| background-size: 400% 100%; | |
| animation: fp-shimmer 1.4s ease infinite; | |
| border-radius: 4px; cursor: default; | |
| } | |
| @keyframes fp-shimmer { | |
| 0% { background-position: 100% 0; } | |
| 100% { background-position: -100% 0; } | |
| } | |
| /* ── View mode toolbar (inside spoiler body) ── */ | |
| #fp-view-toolbar { | |
| display: flex; align-items: center; gap: 8px; | |
| padding: 6px 0 10px; font-family: sans-serif; | |
| } | |
| .fp-view-btn { | |
| background: #2a4a6b; color: #fff; | |
| border: 1px solid #4a7ab5; border-radius: 4px; | |
| padding: 5px 13px; cursor: pointer; font-size: 13px; | |
| transition: background 0.2s; | |
| } | |
| .fp-view-btn:hover { background: #3a6a9b; } | |
| .fp-view-btn.active { background: #4a8ac5; border-color: #6aaaf5; } | |
| /* ── Images wrapper ── */ | |
| #fp-images-wrapper .fp-post-img { width: 100%; height: auto; margin: 6px 0; } | |
| #fp-images-wrapper.fp-grid-mode { | |
| display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; | |
| } | |
| #fp-images-wrapper.fp-grid-mode .fp-post-img { margin: 0; } | |
| /* ── Lightbox: full-screen overlay, children are absolutely positioned ── */ | |
| #fp-lightbox { | |
| position: fixed; inset: 0; z-index: 99999; | |
| background: rgba(0,0,0,0.92); | |
| opacity: 0; pointer-events: none; | |
| transition: opacity 0.22s ease; | |
| } | |
| #fp-lightbox.open { opacity: 1; pointer-events: all; } | |
| /* ── Auto-hide UI when mouse idle ── */ | |
| .fp-tb, .fp-side-btn { transition: opacity 0.35s ease, background 0.2s; } | |
| #fp-lightbox.fp-ui-hidden .fp-tb, | |
| #fp-lightbox.fp-ui-hidden .fp-side-btn { opacity: 0; pointer-events: none; } | |
| #fp-lightbox.fp-ui-hidden, | |
| #fp-lightbox.fp-ui-hidden * { cursor: none !important; } | |
| /* ── Floating toolbars — centered horizontally, pinned top/bottom ── */ | |
| .fp-tb { | |
| position: absolute; left: 50%; transform: translateX(-50%); | |
| z-index: 20; | |
| display: flex; align-items: center; gap: 8px; | |
| padding: 8px 16px; background: rgba(0,0,0,0.65); | |
| border-radius: 8px; font-family: sans-serif; | |
| white-space: nowrap; | |
| } | |
| .fp-tb-top { top: 12px; } | |
| .fp-tb-bot { bottom: 12px; } | |
| .fp-tb button { | |
| background: rgba(255,255,255,0.15); border: none; color: #fff; | |
| width: 36px; height: 36px; border-radius: 6px; font-size: 1rem; | |
| cursor: pointer; transition: background 0.2s; | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .fp-tb button:hover { background: rgba(255,255,255,0.35); } | |
| .fp-tb button.active { background: rgba(255,200,0,0.4); } | |
| .fp-counter { | |
| color: rgba(255,255,255,0.8); font-size: 14px; | |
| min-width: 50px; text-align: center; | |
| } | |
| .fp-speed-sel { | |
| background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); | |
| color: #fff; border-radius: 6px; padding: 4px 6px; font-size: 13px; | |
| cursor: pointer; height: 36px; | |
| } | |
| .fp-speed-sel option { background: #222; } | |
| /* ── Image area fills the entire overlay ── */ | |
| #fp-img-wrap { | |
| position: absolute; inset: 0; | |
| overflow: hidden; | |
| } | |
| #fp-img-wrap.zoomed { overflow: auto; cursor: grab; } | |
| #fp-img-wrap.dragging { cursor: grabbing !important; } | |
| /* Container is sized by the spacer (in-flow); images float on top */ | |
| #fp-img-container { | |
| position: relative; | |
| min-width: 100%; min-height: 100%; | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| #fp-zoom-spacer { | |
| width: 100vw; height: 100vh; | |
| display: block; flex-shrink: 0; pointer-events: none; | |
| } | |
| #fp-img-a, #fp-img-b { | |
| position: absolute; top: 50%; left: 50%; | |
| transform: translate(-50%, -50%); | |
| max-width: 100vw; max-height: 100vh; | |
| object-fit: contain; user-select: none; pointer-events: none; | |
| transition: opacity 0.5s ease; | |
| box-shadow: 0 0 40px rgba(0,0,0,0.6); | |
| } | |
| /* ── Zoom level badge / inline input ── */ | |
| .fp-zoom-val { | |
| color: rgba(255,255,255,0.75); font-size: 13px; | |
| min-width: 44px; text-align: center; cursor: text; | |
| padding: 0 2px; line-height: 36px; | |
| } | |
| .fp-zoom-val:hover { color: #fff; } | |
| .fp-zoom-input { | |
| width: 58px; height: 28px; background: rgba(255,255,255,0.15); | |
| border: 1px solid rgba(100,180,255,0.7); color: #fff; | |
| border-radius: 4px; padding: 0 6px; font-size: 13px; | |
| text-align: center; box-sizing: border-box; | |
| -moz-appearance: textfield; outline: none; | |
| } | |
| .fp-zoom-input::-webkit-outer-spin-button, | |
| .fp-zoom-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } | |
| /* ── Side arrows — absolute in lightbox, float over image ── */ | |
| .fp-side-btn { | |
| position: absolute; top: 50%; transform: translateY(-50%); | |
| background: rgba(0,0,0,0.45); border: none; color: #fff; | |
| font-size: 2rem; width: 50px; height: 80px; cursor: pointer; | |
| border-radius: 4px; transition: background 0.2s; user-select: none; | |
| z-index: 10; | |
| } | |
| .fp-side-btn:hover { background: rgba(255,255,255,0.35); } | |
| #fp-prev-side { left: 8px; } | |
| #fp-next-side { right: 8px; } | |
| `; | |
| document.head.appendChild(style); | |
| /* ── Lightbox DOM: toolbars float over full-screen image area ── */ | |
| const lightbox = document.createElement('div'); | |
| lightbox.id = 'fp-lightbox'; | |
| lightbox.innerHTML = ` | |
| <div class="fp-tb fp-tb-top" id="fp-tb-top"> | |
| <button id="fp-close-top" title="Закрыть (Esc)">✕</button> | |
| <button id="fp-prev-top" title="Назад (←)">←</button> | |
| <span id="fp-counter-top" class="fp-counter">1 / 1</span> | |
| <button id="fp-next-top" title="Вперёд (→)">→</button> | |
| <button id="fp-play-top" title="Слайдшоу (Space)">▶</button> | |
| <select id="fp-speed-top" class="fp-speed-sel" title="Скорость"> | |
| <option value="2000">2с</option> | |
| <option value="4000" selected>4с</option> | |
| <option value="6000">6с</option> | |
| <option value="10000">10с</option> | |
| </select> | |
| <button id="fp-zout-top" title="Уменьшить (-)">−</button> | |
| <span id="fp-zval-top" class="fp-zoom-val" title="Клик — ввести масштаб; 0 — сбросить">100%</span> | |
| <button id="fp-zin-top" title="Увеличить (+)">+</button> | |
| <button id="fp-fs-top" title="На весь экран (F)">⛶</button> | |
| </div> | |
| <div id="fp-img-wrap"> | |
| <div id="fp-img-container"> | |
| <div id="fp-zoom-spacer"></div> | |
| <img id="fp-img-a" src="" alt=""> | |
| <img id="fp-img-b" src="" alt="" style="opacity:0"> | |
| </div> | |
| </div> | |
| <button class="fp-side-btn" id="fp-prev-side">←</button> | |
| <button class="fp-side-btn" id="fp-next-side">→</button> | |
| <div class="fp-tb fp-tb-bot" id="fp-tb-bot"> | |
| <button id="fp-close-bot" title="Закрыть (Esc)">✕</button> | |
| <button id="fp-prev-bot" title="Назад (←)">←</button> | |
| <span id="fp-counter-bot" class="fp-counter">1 / 1</span> | |
| <button id="fp-next-bot" title="Вперёд (→)">→</button> | |
| <button id="fp-play-bot" title="Слайдшоу (Space)">▶</button> | |
| <select id="fp-speed-bot" class="fp-speed-sel" title="Скорость"> | |
| <option value="2000">2с</option> | |
| <option value="4000" selected>4с</option> | |
| <option value="6000">6с</option> | |
| <option value="10000">10с</option> | |
| </select> | |
| <button id="fp-zout-bot" title="Уменьшить (-)">−</button> | |
| <span id="fp-zval-bot" class="fp-zoom-val" title="Клик — ввести масштаб; 0 — сбросить">100%</span> | |
| <button id="fp-zin-bot" title="Увеличить (+)">+</button> | |
| <button id="fp-fs-bot" title="На весь экран (F)">⛶</button> | |
| </div> | |
| `; | |
| document.body.appendChild(lightbox); | |
| let images = []; | |
| let current = 0; | |
| let slideshowTimer = null; | |
| let active = 'a'; | |
| /* Drag-to-pan state */ | |
| let isDragging = false, didDrag = false; | |
| let dragStartX = 0, dragStartY = 0, scrollStartX = 0, scrollStartY = 0; | |
| /* ── Auto-hide UI after 2 s of mouse inactivity ── */ | |
| let uiHideTimer = null; | |
| const showUI = () => { | |
| lightbox.classList.remove('fp-ui-hidden'); | |
| clearTimeout(uiHideTimer); | |
| uiHideTimer = setTimeout(() => lightbox.classList.add('fp-ui-hidden'), 2000); | |
| }; | |
| lightbox.addEventListener('mousemove', showUI); | |
| lightbox.addEventListener('mouseenter', showUI); | |
| const getEl = (id) => document.getElementById(`fp-img-${id}`); | |
| const getSpeed = () => parseInt(document.getElementById('fp-speed-top').value); | |
| /* Repositions side arrows just outside the actual rendered image edges */ | |
| const positionArrows = () => { | |
| const imgEl = getEl(active); | |
| if (!imgEl || !imgEl.naturalWidth) return; | |
| const ir = imgEl.getBoundingClientRect(); | |
| const arrowW = 50, gap = 6; | |
| const prevBtn = document.getElementById('fp-prev-side'); | |
| const nextBtn = document.getElementById('fp-next-side'); | |
| if (prevBtn) { | |
| prevBtn.style.left = Math.max(4, ir.left - arrowW - gap) + 'px'; | |
| prevBtn.style.right = 'auto'; | |
| } | |
| if (nextBtn) { | |
| nextBtn.style.right = Math.max(4, window.innerWidth - ir.right - arrowW - gap) + 'px'; | |
| nextBtn.style.left = 'auto'; | |
| } | |
| }; | |
| const onResize = () => { applyZoom(); positionArrows(); }; | |
| /* Keep both toolbars in sync */ | |
| const updateUI = () => { | |
| const txt = `${current + 1} / ${images.length}`; | |
| const isPlaying = !!slideshowTimer; | |
| ['top', 'bot'].forEach(s => { | |
| const counter = document.getElementById(`fp-counter-${s}`); | |
| if (counter) counter.textContent = txt; | |
| const btn = document.getElementById(`fp-play-${s}`); | |
| if (btn) { | |
| btn.innerHTML = isPlaying ? '▮▮' : '▶'; | |
| btn.classList.toggle('active', isPlaying); | |
| } | |
| }); | |
| }; | |
| const open = (idx) => { | |
| current = (idx + images.length) % images.length; | |
| updateUI(); | |
| const isOpen = lightbox.classList.contains('open'); | |
| const nextSlot = active === 'a' ? 'b' : 'a'; | |
| const nextEl = getEl(nextSlot); | |
| const activeEl = getEl(active); | |
| if (!isOpen) { | |
| activeEl.src = images[current]; | |
| activeEl.style.opacity = '1'; | |
| nextEl.style.opacity = '0'; | |
| requestAnimationFrame(() => lightbox.classList.add('open')); | |
| showUI(); | |
| const onLoaded = () => { applyZoom(); positionArrows(); activeEl.onload = null; }; | |
| if (activeEl.complete && activeEl.naturalWidth) onLoaded(); | |
| else activeEl.onload = onLoaded; | |
| window.addEventListener('resize', onResize); | |
| return; | |
| } | |
| /* Center scroll when navigating while zoomed */ | |
| const wrap = document.getElementById('fp-img-wrap'); | |
| if (wrap && zoomLevel > 1) { | |
| wrap.scrollLeft = (wrap.scrollWidth - wrap.clientWidth) / 2; | |
| wrap.scrollTop = (wrap.scrollHeight - wrap.clientHeight) / 2; | |
| } | |
| nextEl.style.opacity = '0'; | |
| nextEl.src = images[current]; | |
| const doFade = () => { | |
| nextEl.style.opacity = '1'; | |
| activeEl.style.opacity = '0'; | |
| active = nextSlot; | |
| nextEl.onload = null; | |
| positionArrows(); | |
| }; | |
| if (nextEl.complete && nextEl.naturalWidth) doFade(); | |
| else nextEl.onload = doFade; | |
| }; | |
| const close = () => { | |
| stopSlideshow(); | |
| clearTimeout(uiHideTimer); | |
| lightbox.classList.remove('fp-ui-hidden'); | |
| window.removeEventListener('resize', onResize); | |
| if (document.fullscreenElement) { | |
| document.exitFullscreen?.() || document.webkitExitFullscreen?.(); | |
| } | |
| lightbox.classList.remove('open'); | |
| setTimeout(() => { | |
| if (lightbox.classList.contains('open')) return; | |
| active = 'a'; | |
| getEl('a').style.opacity = '1'; | |
| getEl('b').style.opacity = '0'; | |
| }, 230); | |
| }; | |
| const prev = () => { stopSlideshow(); open(current - 1); }; | |
| const next = () => { stopSlideshow(); open(current + 1); }; | |
| const startSlideshow = () => { | |
| slideshowTimer = setInterval(() => open((current + 1) % images.length), getSpeed()); | |
| updateUI(); | |
| }; | |
| const stopSlideshow = () => { | |
| if (!slideshowTimer) return; | |
| clearInterval(slideshowTimer); | |
| slideshowTimer = null; | |
| updateUI(); | |
| }; | |
| const toggleSlideshow = () => slideshowTimer ? stopSlideshow() : startSlideshow(); | |
| /* Fullscreen toggle using Fullscreen API */ | |
| const toggleFullscreen = () => { | |
| if (!document.fullscreenElement) { | |
| lightbox.requestFullscreen?.() || lightbox.webkitRequestFullscreen?.(); | |
| } else { | |
| document.exitFullscreen?.() || document.webkitExitFullscreen?.(); | |
| } | |
| }; | |
| const updateFsBtn = () => { | |
| const isFs = !!document.fullscreenElement; | |
| ['top', 'bot'].forEach(s => { | |
| const btn = document.getElementById(`fp-fs-${s}`); | |
| if (btn) { btn.innerHTML = isFs ? '⤢' : '⛶'; btn.classList.toggle('active', isFs); } | |
| }); | |
| }; | |
| document.addEventListener('fullscreenchange', updateFsBtn); | |
| /* ── Zoom ── */ | |
| const ZOOM_STEPS = [0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0]; | |
| let zoomLevel = parseFloat(localStorage.getItem('fp-zoom') || '1'); | |
| const applyZoom = () => { | |
| localStorage.setItem('fp-zoom', zoomLevel); | |
| const label = Math.round(zoomLevel * 100) + '%'; | |
| ['top', 'bot'].forEach(s => { | |
| const v = document.getElementById(`fp-zval-${s}`); | |
| if (v) v.textContent = label; | |
| }); | |
| const imgEl = getEl(active); | |
| const wrap = document.getElementById('fp-img-wrap'); | |
| const spacer = document.getElementById('fp-zoom-spacer'); | |
| if (zoomLevel === 1) { | |
| [getEl('a'), getEl('b')].forEach(img => { | |
| if (!img) return; | |
| img.style.width = ''; img.style.height = ''; | |
| img.style.maxWidth = '100vw'; img.style.maxHeight = '100vh'; | |
| img.style.transform = 'translate(-50%, -50%)'; | |
| }); | |
| if (spacer) { spacer.style.width = '100vw'; spacer.style.height = '100vh'; } | |
| if (wrap) { wrap.classList.remove('zoomed'); wrap.scrollLeft = 0; wrap.scrollTop = 0; } | |
| return; | |
| } | |
| /* Compute the fitted base size (how image renders at zoom 1.0) */ | |
| const maxW = window.innerWidth; | |
| const maxH = window.innerHeight; | |
| let baseW = maxW, baseH = maxH; | |
| if (imgEl && imgEl.naturalWidth && imgEl.naturalHeight) { | |
| const r = imgEl.naturalWidth / imgEl.naturalHeight; | |
| if (maxW / r <= maxH) { baseW = maxW; baseH = maxW / r; } | |
| else { baseH = maxH; baseW = maxH * r; } | |
| } | |
| const scaledW = baseW * zoomLevel; | |
| const scaledH = baseH * zoomLevel; | |
| [getEl('a'), getEl('b')].forEach(img => { | |
| if (!img) return; | |
| img.style.maxWidth = 'none'; img.style.maxHeight = 'none'; | |
| img.style.width = scaledW + 'px'; | |
| img.style.height = scaledH + 'px'; | |
| img.style.transform = 'translate(-50%, -50%)'; | |
| }); | |
| if (spacer) { spacer.style.width = scaledW + 'px'; spacer.style.height = scaledH + 'px'; } | |
| if (wrap) { | |
| wrap.classList.add('zoomed'); | |
| requestAnimationFrame(() => { | |
| wrap.scrollLeft = (wrap.scrollWidth - wrap.clientWidth) / 2; | |
| wrap.scrollTop = (wrap.scrollHeight - wrap.clientHeight) / 2; | |
| }); | |
| } | |
| }; | |
| const zoomIn = () => { const i = ZOOM_STEPS.findIndex(z => z > zoomLevel); if (i !== -1) { zoomLevel = ZOOM_STEPS[i]; applyZoom(); positionArrows(); } }; | |
| const zoomOut = () => { const arr = [...ZOOM_STEPS].reverse(); const i = arr.findIndex(z => z < zoomLevel); if (i !== -1) { zoomLevel = arr[i]; applyZoom(); positionArrows(); } }; | |
| const zoomReset = () => { zoomLevel = 1; applyZoom(); positionArrows(); }; | |
| /* Click the zoom badge → inline number input; Enter/blur to apply, Esc to cancel */ | |
| const editZoom = () => { | |
| if (document.getElementById('fp-zinput-top')) return; | |
| const badges = { top: document.getElementById('fp-zval-top'), bot: document.getElementById('fp-zval-bot') }; | |
| let settled = false; | |
| const commit = () => { | |
| if (settled) return; | |
| settled = true; | |
| const inp = document.getElementById('fp-zinput-top'); | |
| if (inp) { | |
| const v = parseFloat(inp.value); | |
| if (!isNaN(v) && v >= 1 && v <= 2000) { zoomLevel = v / 100; applyZoom(); positionArrows(); } | |
| } | |
| restore(); | |
| }; | |
| const restore = () => { | |
| ['top', 'bot'].forEach(s => { | |
| const inp = document.getElementById(`fp-zinput-${s}`); | |
| if (inp && badges[s]) inp.replaceWith(badges[s]); | |
| }); | |
| }; | |
| ['top', 'bot'].forEach((s, i) => { | |
| if (!badges[s]) return; | |
| const input = document.createElement('input'); | |
| input.type = 'number'; input.min = 1; input.max = 2000; | |
| input.className = 'fp-zoom-input'; input.id = `fp-zinput-${s}`; | |
| input.value = Math.round(zoomLevel * 100); | |
| input.onkeydown = (e) => { | |
| e.stopPropagation(); | |
| if (e.key === 'Enter') commit(); | |
| if (e.key === 'Escape') { settled = true; restore(); } | |
| }; | |
| input.onblur = () => setTimeout(commit, 120); | |
| badges[s].replaceWith(input); | |
| if (i === 0) requestAnimationFrame(() => { input.select(); input.focus(); }); | |
| }); | |
| }; | |
| const syncSpeed = (from, to) => { | |
| document.getElementById(`fp-speed-${to}`).value = | |
| document.getElementById(`fp-speed-${from}`).value; | |
| }; | |
| /* Top toolbar */ | |
| document.getElementById('fp-close-top').onclick = close; | |
| document.getElementById('fp-prev-top').onclick = prev; | |
| document.getElementById('fp-next-top').onclick = next; | |
| document.getElementById('fp-play-top').onclick = toggleSlideshow; | |
| document.getElementById('fp-zout-top').onclick = zoomOut; | |
| document.getElementById('fp-zval-top').onclick = editZoom; | |
| document.getElementById('fp-zin-top').onclick = zoomIn; | |
| document.getElementById('fp-fs-top').onclick = toggleFullscreen; | |
| document.getElementById('fp-speed-top').onchange = () => { | |
| syncSpeed('top', 'bot'); | |
| if (slideshowTimer) { stopSlideshow(); startSlideshow(); } | |
| }; | |
| /* Bottom toolbar */ | |
| document.getElementById('fp-close-bot').onclick = close; | |
| document.getElementById('fp-prev-bot').onclick = prev; | |
| document.getElementById('fp-next-bot').onclick = next; | |
| document.getElementById('fp-play-bot').onclick = toggleSlideshow; | |
| document.getElementById('fp-zout-bot').onclick = zoomOut; | |
| document.getElementById('fp-zval-bot').onclick = editZoom; | |
| document.getElementById('fp-zin-bot').onclick = zoomIn; | |
| document.getElementById('fp-fs-bot').onclick = toggleFullscreen; | |
| document.getElementById('fp-speed-bot').onchange = () => { | |
| syncSpeed('bot', 'top'); | |
| if (slideshowTimer) { stopSlideshow(); startSlideshow(); } | |
| }; | |
| /* Side arrows */ | |
| document.getElementById('fp-prev-side').onclick = (e) => { e.stopPropagation(); prev(); }; | |
| document.getElementById('fp-next-side').onclick = (e) => { e.stopPropagation(); next(); }; | |
| /* ── Drag-to-pan ── */ | |
| const imgWrap = document.getElementById('fp-img-wrap'); | |
| imgWrap.addEventListener('mousedown', (e) => { | |
| if (e.button !== 0) return; | |
| isDragging = true; | |
| didDrag = false; | |
| dragStartX = e.clientX; | |
| dragStartY = e.clientY; | |
| scrollStartX = imgWrap.scrollLeft; | |
| scrollStartY = imgWrap.scrollTop; | |
| imgWrap.classList.add('dragging'); | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!isDragging) return; | |
| const dx = e.clientX - dragStartX; | |
| const dy = e.clientY - dragStartY; | |
| if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didDrag = true; | |
| imgWrap.scrollLeft = scrollStartX - dx; | |
| imgWrap.scrollTop = scrollStartY - dy; | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (!isDragging) return; | |
| isDragging = false; | |
| imgWrap.classList.remove('dragging'); | |
| }); | |
| /* Container click: on image → navigate L/R half, outside image → close */ | |
| const container = document.getElementById('fp-img-container'); | |
| container.onclick = (e) => { | |
| if (didDrag) { didDrag = false; return; } | |
| const imgEl = getEl(active); | |
| if (imgEl) { | |
| const ir = imgEl.getBoundingClientRect(); | |
| /* Clamp to viewport so partial-scroll doesn't give wrong zone */ | |
| const vl = Math.max(ir.left, 0), vr = Math.min(ir.right, window.innerWidth); | |
| const vt = Math.max(ir.top, 0), vb = Math.min(ir.bottom, window.innerHeight); | |
| if (e.clientX >= vl && e.clientX <= vr && e.clientY >= vt && e.clientY <= vb) { | |
| if (e.clientX < vl + (vr - vl) / 2) prev(); else next(); | |
| return; | |
| } | |
| } | |
| close(); | |
| }; | |
| container.onmousemove = (e) => { | |
| if (isDragging) return; | |
| const imgEl = getEl(active); | |
| if (!imgEl) { container.style.cursor = 'default'; return; } | |
| const ir = imgEl.getBoundingClientRect(); | |
| const vl = Math.max(ir.left, 0), vr = Math.min(ir.right, window.innerWidth); | |
| const vt = Math.max(ir.top, 0), vb = Math.min(ir.bottom, window.innerHeight); | |
| if (e.clientX >= vl && e.clientX <= vr && e.clientY >= vt && e.clientY <= vb) { | |
| container.style.cursor = e.clientX < vl + (vr - vl) / 2 ? 'w-resize' : 'e-resize'; | |
| } else { | |
| container.style.cursor = 'default'; | |
| } | |
| }; | |
| /* Keyboard shortcuts */ | |
| document.addEventListener('keydown', (e) => { | |
| if (!lightbox.classList.contains('open')) return; | |
| if (e.key === 'Escape') close(); | |
| if (e.key === 'ArrowLeft') prev(); | |
| if (e.key === 'ArrowRight') next(); | |
| if (e.key === ' ') { e.preventDefault(); toggleSlideshow(); } | |
| if (e.key === 'f' || e.key === 'F') toggleFullscreen(); | |
| if (e.key === '+' || e.key === '=') zoomIn(); | |
| if (e.key === '-' || e.key === '_') zoomOut(); | |
| if (e.key === '0') zoomReset(); | |
| }); | |
| /* ── View mode (screenshot spoiler) ─────────────────────────────────────── */ | |
| let viewMode = localStorage.getItem('fp-view-mode') || 'full'; | |
| const applyViewMode = (mode) => { | |
| viewMode = mode; | |
| localStorage.setItem('fp-view-mode', mode); | |
| const w = document.getElementById('fp-images-wrapper'); | |
| if (w) w.classList.toggle('fp-grid-mode', mode === 'grid'); | |
| document.querySelectorAll('.fp-view-btn').forEach(b => { | |
| b.classList.toggle('active', b.dataset.mode === mode); | |
| }); | |
| }; | |
| const findScreenshotBody = () => { | |
| for (const h of document.querySelectorAll('.sp-head')) { | |
| if (/скриншот/i.test(h.textContent)) { | |
| const b = h.nextElementSibling; | |
| if (b && b.classList.contains('sp-body')) return b; | |
| } | |
| } | |
| return null; | |
| }; | |
| const setupViewToolbar = (spoilerBody) => { | |
| const toolbar = document.createElement('div'); | |
| toolbar.id = 'fp-view-toolbar'; | |
| const btnFull = document.createElement('button'); | |
| btnFull.className = 'fp-view-btn'; btnFull.dataset.mode = 'full'; | |
| btnFull.textContent = '▬ Полная ширина'; | |
| btnFull.onclick = () => applyViewMode('full'); | |
| const btnGrid = document.createElement('button'); | |
| btnGrid.className = 'fp-view-btn'; btnGrid.dataset.mode = 'grid'; | |
| btnGrid.textContent = '⊞ Сетка 3×'; | |
| btnGrid.onclick = () => applyViewMode('grid'); | |
| toolbar.append(btnFull, btnGrid); | |
| spoilerBody.insertBefore(toolbar, spoilerBody.firstChild); | |
| }; | |
| const wrapImages = (spoilerBody) => { | |
| let wrapper = document.getElementById('fp-images-wrapper'); | |
| if (!wrapper) { | |
| wrapper = document.createElement('div'); | |
| wrapper.id = 'fp-images-wrapper'; | |
| const anchor = spoilerBody.querySelector('#fp-view-toolbar')?.nextSibling ?? spoilerBody.firstChild; | |
| spoilerBody.insertBefore(wrapper, anchor); | |
| } | |
| spoilerBody.querySelectorAll('.fp-post-img').forEach(img => { | |
| if (img.parentElement !== wrapper) wrapper.appendChild(img); | |
| }); | |
| applyViewMode(viewMode); | |
| }; | |
| /* ── URL helpers ──────────────────────────────────────────────────────────── */ | |
| const getDirectImageUrl = (link) => { | |
| if (link.includes('out.php?url=')) { | |
| const match = link.match(/[?&]url=([^&]+)/); | |
| if (match) return decodeURIComponent(match[1]); | |
| } | |
| if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(link)) return link; | |
| return null; | |
| }; | |
| const getSignedUrl = (bigUrl) => { | |
| const match = bigUrl.match(/https?:\/\/i(\d+)\.fastpic\.org\/big\/(\d{4}\/\d{4})\/[^/]+\/(.+)/); | |
| if (!match) return Promise.resolve(null); | |
| const viewUrl = `https://fastpic.org/view/${match[1]}/${match[2]}/${match[3]}.html`; | |
| return new Promise((resolve) => { | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url: viewUrl, | |
| headers: { 'User-Agent': navigator.userAgent }, | |
| onload: (r) => { | |
| if (r.status !== 200) { resolve(null); return; } | |
| const m = r.responseText.match(/src="(https?:\/\/i\d+\.fastpic\.org\/big\/[^"]*\?[^"]*md5=[^"]+)"/); | |
| resolve(m ? m[1].replace(/&/g, '&') : null); | |
| }, | |
| onerror: () => resolve(null), | |
| ontimeout: () => resolve(null), | |
| }); | |
| }); | |
| }; | |
| /* ── Processing: two-phase (DOM first, then fetch) ──────────────────────── */ | |
| const CONCURRENCY = 3; | |
| const prepareLink = (a) => { | |
| let link = a.href.replace('http://', 'https://'); | |
| if (!link.startsWith('https://')) return null; | |
| let directUrl = getDirectImageUrl(link); | |
| /* VFL support: full-size URL derived from thumbnail _s → no suffix */ | |
| if (!directUrl) { | |
| const postImg = a.querySelector('.postImg'); | |
| const thumbSrc = (postImg?.getAttribute('src') || postImg?.getAttribute('title') || '') | |
| .replace('http://', 'https://'); | |
| if (/images\.vfl\.ru\/ii\//i.test(thumbSrc)) { | |
| directUrl = thumbSrc.replace(/_s(\.(jpe?g|png|gif|webp))$/i, '$1'); | |
| } | |
| } | |
| if (!directUrl) return null; | |
| a.dataset.replaced = '1'; | |
| const idx = images.length; | |
| images.push(null); | |
| const img = document.createElement('img'); | |
| img.className = 'fp-post-img fp-loading'; | |
| a.parentNode.replaceChild(img, a); | |
| return { img, directUrl, idx }; | |
| }; | |
| const fetchForSlot = async ({ img, directUrl, idx }) => { | |
| /* Fastpic needs a signed URL fetched from the view page; all other hosts are direct */ | |
| let finalUrl; | |
| if (/fastpic\.org\/big\//i.test(directUrl)) { | |
| finalUrl = await getSignedUrl(directUrl); | |
| } else { | |
| finalUrl = directUrl; | |
| } | |
| if (!finalUrl) { img.classList.remove('fp-loading'); images.splice(idx, 1); return; } | |
| images[idx] = finalUrl; | |
| img.src = finalUrl; | |
| img.onload = () => img.classList.remove('fp-loading'); | |
| img.onerror = () => { img.classList.remove('fp-loading'); images.splice(idx, 1); }; | |
| }; | |
| const reindexOnclick = () => { | |
| images = images.filter(Boolean); | |
| document.querySelectorAll('.fp-post-img').forEach((img, i) => { | |
| img.onclick = () => open(i); | |
| }); | |
| }; | |
| const processLinks = async (links) => { | |
| if (!links.length) return; | |
| const tasks = links.map(prepareLink).filter(Boolean); | |
| if (spoilerBody) wrapImages(spoilerBody); | |
| const queue = [...tasks]; | |
| const worker = async () => { | |
| while (queue.length > 0) await fetchForSlot(queue.shift()); | |
| }; | |
| await Promise.all(Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, worker)); | |
| reindexOnclick(); | |
| }; | |
| const findNewLinks = () => | |
| Array.from(document.querySelectorAll("a.postLink:has(.postImg):not([data-replaced])")); | |
| /* ── Init ──────────────────────────────────────────────────────────────────── */ | |
| const spoilerBody = findScreenshotBody(); | |
| if (spoilerBody) setupViewToolbar(spoilerBody); | |
| await processLinks(findNewLinks()); | |
| let pending = false; | |
| const observer = new MutationObserver(async () => { | |
| if (pending) return; | |
| const newLinks = findNewLinks(); | |
| if (!newLinks.length) return; | |
| pending = true; | |
| await processLinks(newLinks); | |
| pending = false; | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment