Reddit Inline Image Gallery Carousel

Interactive Inline Gallery Carousel with full-res Images for Reddit Preview Search Gallery.

当前为 2025-02-05 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Reddit Inline Image Gallery Carousel
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0
  5. // @description Interactive Inline Gallery Carousel with full-res Images for Reddit Preview Search Gallery.
  6. // @author UniverseDev
  7. // @license GPL-3.0-or-later
  8. // @icon https://www.reddit.com/favicon.ico
  9. // @match https://www.reddit.com/search/*type=media
  10. // @match https://www.reddit.com/search/?q=.*&type=media.*
  11. // @grant GM_addStyle
  12. // @grant GM.xmlHttpRequest
  13. // ==/UserScript==
  14.  
  15. (() => {
  16. 'use strict';
  17.  
  18. GM_addStyle(`
  19. .reddit-carousel {
  20. position: relative;
  21. overflow: hidden;
  22. width: 100%;
  23. min-height: 200px;
  24. margin-bottom: 10px;
  25. border: 1px solid #ddd;
  26. cursor: default;
  27. }
  28. .reddit-carousel-slide-container {
  29. display: flex;
  30. transition: transform 125ms ease;
  31. }
  32. .reddit-carousel-slide {
  33. flex: 0 0 100%;
  34. width: 100%;
  35. text-align: center;
  36. min-height: 200px;
  37. }
  38. .reddit-carousel-slide img {
  39. max-width: 100%;
  40. width: 100%;
  41. height: auto;
  42. object-fit: contain;
  43. display: block;
  44. margin: 0 auto;
  45. }
  46. .reddit-carousel-error {
  47. color: red;
  48. font-size: 14px;
  49. padding: 20px;
  50. }
  51. .reddit-carousel-arrow {
  52. position: absolute;
  53. top: 50%;
  54. transform: translateY(-50%);
  55. background: rgba(0,0,0,0.7);
  56. border: none;
  57. width: 30px;
  58. height: 30px;
  59. cursor: pointer;
  60. border-radius: 50%;
  61. display: flex;
  62. align-items: center;
  63. justify-content: center;
  64. z-index: 2;
  65. transition: background 0.2s ease, transform 0.15s ease;
  66. color: #fff;
  67. box-shadow: 0 2px 4px rgba(0,0,0,0.4);
  68. }
  69. .reddit-carousel-arrow:hover {
  70. background: rgba(0,0,0,0.85);
  71. transform: translateY(-50%) scale(1.1);
  72. box-shadow: 0 4px 8px rgba(0,0,0,0.5);
  73. }
  74. .reddit-carousel-arrow:active {
  75. transform: translateY(-50%) scale(0.95);
  76. }
  77. .reddit-carousel-arrow svg {
  78. width: 16px;
  79. height: 16px;
  80. fill: currentColor;
  81. }
  82. .reddit-carousel-arrow.left { left: 10px; display: none; }
  83. .reddit-carousel-arrow.right { right: 10px; display: flex; }
  84. `);
  85.  
  86. const redditCarousel_animateTransition = (container, start, end, duration, callback) => {
  87. const startTime = performance.now();
  88. const step = now => {
  89. const progress = Math.min((now - startTime) / duration, 1);
  90. const ease = 1 - Math.pow(1 - progress, 3);
  91. container.style.transform = `translateX(${start + (end - start) * ease}px)`;
  92. if (progress < 1) requestAnimationFrame(step);
  93. else if (callback) callback();
  94. };
  95. requestAnimationFrame(step);
  96. };
  97.  
  98. const redditCarousel_createCarousel = urls => {
  99. if (!urls?.length) return null;
  100. const carousel = document.createElement('div');
  101. carousel.classList.add('reddit-carousel');
  102. carousel.addEventListener('click', e => {
  103. e.stopPropagation();
  104. e.preventDefault();
  105. });
  106. const slideContainer = document.createElement('div');
  107. slideContainer.classList.add('reddit-carousel-slide-container');
  108. carousel.appendChild(slideContainer);
  109.  
  110. urls.forEach(url => {
  111. const slide = document.createElement('div');
  112. slide.classList.add('reddit-carousel-slide');
  113. const img = document.createElement('img');
  114. img.src = url;
  115. img.alt = "Reddit Gallery Image";
  116. img.onerror = function() {
  117. slide.innerHTML = '<div class="reddit-carousel-error">Image failed to load</div>';
  118. };
  119. img.addEventListener('load', () => {
  120. redditCarousel_recalcDimensions();
  121. redditCarousel_updateArrowVisibility();
  122. });
  123. slide.appendChild(img);
  124. slideContainer.appendChild(slide);
  125. });
  126.  
  127. const extraCounterWrapper = document.createElement('div');
  128. extraCounterWrapper.innerHTML = `<div class="absolute inset-0 overflow-visible flex items-right justify-end">
  129. <button rpl="" class="pointer-events-none m-xs leading-4 pl-2xs pr-2xs py-0 text-sm h-fit button-small px-[var(--rem10)] button-media items-center justify-center button inline-flex ">
  130. <span class="flex items-center justify-center">
  131. <span class="extra-counter-text flex items-center gap-xs">1/${urls.length}</span>
  132. </span>
  133. </button>
  134. </div>`;
  135. carousel.appendChild(extraCounterWrapper);
  136.  
  137. let redditCarousel_currentIndex = 0, redditCarousel_currentOffset = 0;
  138. const redditCarousel_recalcDimensions = () => {
  139. const containerWidth = slideContainer.clientWidth;
  140. redditCarousel_currentOffset = -redditCarousel_currentIndex * containerWidth;
  141. slideContainer.style.transform = `translateX(${redditCarousel_currentOffset}px)`;
  142. };
  143.  
  144. const redditCarousel_updateCounter = () => {
  145. const newCounterText = `${redditCarousel_currentIndex + 1}/${urls.length}`;
  146. const extraCounterText = carousel.querySelector('.extra-counter-text');
  147. if (extraCounterText) {
  148. extraCounterText.textContent = newCounterText;
  149. }
  150. };
  151.  
  152. const redditCarousel_goToSlide = index => {
  153. index = Math.max(0, Math.min(index, urls.length - 1));
  154. const containerWidth = slideContainer.clientWidth;
  155. const startOffset = redditCarousel_currentOffset;
  156. const endOffset = -index * containerWidth;
  157. redditCarousel_animateTransition(slideContainer, startOffset, endOffset, 125, () => {
  158. redditCarousel_currentOffset = endOffset;
  159. redditCarousel_currentIndex = index;
  160. redditCarousel_updateCounter();
  161. redditCarousel_updateArrowVisibility();
  162. });
  163. };
  164.  
  165. let redditCarousel_leftArrow, redditCarousel_rightArrow;
  166. if (urls.length > 1) {
  167. const redditCarousel_createArrow = dir => {
  168. const btn = document.createElement('button');
  169. btn.classList.add('reddit-carousel-arrow', dir);
  170. btn.addEventListener('click', e => {
  171. e.stopPropagation();
  172. e.preventDefault();
  173. });
  174. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  175. svg.setAttribute("viewBox", "0 0 20 20");
  176. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  177. path.setAttribute("d", dir === 'left'
  178. ? "M12.793 19.707l-9-9a1 1 0 0 1 0-1.414l9-9 1.414 1.414L5.914 10l8.293 8.293-1.414 1.414Z"
  179. : "M7.207 19.707l-1.414-1.414L14.086 10 5.793 1.707 7.207.293l9 9a1 1 0 0 1 0 1.414l-9 9Z"
  180. );
  181. svg.appendChild(path);
  182. btn.appendChild(svg);
  183. return btn;
  184. };
  185.  
  186. redditCarousel_leftArrow = redditCarousel_createArrow('left');
  187. redditCarousel_leftArrow.addEventListener('click', () => {
  188. redditCarousel_goToSlide(redditCarousel_currentIndex - 1);
  189. });
  190. carousel.appendChild(redditCarousel_leftArrow);
  191.  
  192. redditCarousel_rightArrow = redditCarousel_createArrow('right');
  193. redditCarousel_rightArrow.addEventListener('click', () => {
  194. redditCarousel_goToSlide(redditCarousel_currentIndex + 1);
  195. });
  196. carousel.appendChild(redditCarousel_rightArrow);
  197. }
  198.  
  199. const redditCarousel_updateArrowVisibility = () => {
  200. if (urls.length <= 1) return;
  201. if (redditCarousel_currentIndex === 0) {
  202. redditCarousel_leftArrow.style.display = 'none';
  203. redditCarousel_rightArrow.style.display = 'flex';
  204. } else if (redditCarousel_currentIndex === urls.length - 1) {
  205. redditCarousel_leftArrow.style.display = 'flex';
  206. redditCarousel_rightArrow.style.display = 'none';
  207. } else {
  208. redditCarousel_leftArrow.style.display = 'flex';
  209. redditCarousel_rightArrow.style.display = 'flex';
  210. }
  211. };
  212.  
  213. let redditCarousel_resizeTimeout;
  214. window.addEventListener('resize', () => {
  215. clearTimeout(redditCarousel_resizeTimeout);
  216. redditCarousel_resizeTimeout = setTimeout(() => {
  217. redditCarousel_recalcDimensions();
  218. redditCarousel_updateArrowVisibility();
  219. }, 100);
  220. });
  221. redditCarousel_updateArrowVisibility();
  222. return carousel;
  223. };
  224.  
  225. const redditCarousel_fetchAndProcessGallery = (postURL, container) => {
  226. GM.xmlHttpRequest({
  227. url: `${postURL}.json`,
  228. method: 'GET',
  229. onload: response => {
  230. if (response.status >= 200 && response.status < 300) {
  231. try {
  232. const jsonData = JSON.parse(response.responseText);
  233. const postData = jsonData[0]?.data?.children[0]?.data;
  234. if (!postData?.gallery_data) return;
  235. const { items } = postData.gallery_data;
  236. const mediaMeta = postData.media_metadata;
  237. const urls = items.reduce((acc, item) => {
  238. const meta = mediaMeta[item.media_id];
  239. if (meta && meta.id && meta.m) {
  240. let ext = "jpg";
  241. if (meta.m.includes("png")) ext = "png";
  242. else if (meta.m.includes("gif")) ext = "gif";
  243. else if (meta.m.includes("webp")) ext = "webp";
  244. acc.push(`https://i.redd.it/${meta.id}.${ext}`);
  245. }
  246. return acc;
  247. }, []);
  248. const carousel = redditCarousel_createCarousel(urls);
  249. if (carousel) {
  250. container.innerHTML = '';
  251. container.appendChild(carousel);
  252. const parentLink = container.closest('a');
  253. if (parentLink) {
  254. parentLink.addEventListener('click', e => {
  255. e.stopPropagation();
  256. e.preventDefault();
  257. });
  258. }
  259. }
  260. } catch (error) {
  261. console.error("JSON parse error:", error);
  262. }
  263. }
  264. },
  265. onerror: err => console.error("Request failed:", err)
  266. });
  267. };
  268.  
  269. const redditCarousel_galleryObserver = new IntersectionObserver((entries, observer) => {
  270. entries.forEach(entry => {
  271. if (entry.isIntersecting) {
  272. const container = entry.target;
  273. if (!container.hasAttribute('data-gallery-intersected')) {
  274. container.setAttribute('data-gallery-intersected', 'true');
  275. const postUnit = container.closest('div[data-id="search-media-post-unit"]');
  276. const postLink = postUnit?.querySelector('a.no-underline');
  277. if (postLink?.href) {
  278. redditCarousel_fetchAndProcessGallery(postLink.href, container);
  279. }
  280. }
  281. observer.unobserve(container);
  282. }
  283. });
  284. }, { threshold: 0.1 });
  285.  
  286. const redditCarousel_processSearchResults = () => {
  287. document.querySelectorAll('div[data-id="search-media-post-unit"]').forEach(post => {
  288. if (post.hasAttribute('data-gallery-checked')) return;
  289. post.setAttribute('data-gallery-checked', 'true');
  290. const indicator = post.querySelector('div.absolute.inset-0.overflow-visible.flex.items-right.justify-end button span');
  291. if (indicator?.textContent.includes('/')) {
  292. const container = post.querySelector('shreddit-aspect-ratio');
  293. if (container) redditCarousel_galleryObserver.observe(container);
  294. }
  295. });
  296. };
  297.  
  298. let redditCarousel_ticking = false;
  299. window.addEventListener('scroll', () => {
  300. if (!redditCarousel_ticking) {
  301. requestAnimationFrame(() => {
  302. redditCarousel_processSearchResults();
  303. redditCarousel_ticking = false;
  304. });
  305. redditCarousel_ticking = true;
  306. }
  307. });
  308. window.addEventListener('load', redditCarousel_processSearchResults);
  309. })();