Reddit Inline Image Gallery Carousel

Interactive Inline Gallery Carousel with full-res Images for Reddit 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 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. urls.forEach(url => {
  110. const slide = document.createElement('div');
  111. slide.classList.add('reddit-carousel-slide');
  112. const img = document.createElement('img');
  113. img.src = url;
  114. img.alt = "Reddit Gallery Image";
  115. img.onerror = function() {
  116. slide.innerHTML = '<div class="reddit-carousel-error">Image failed to load</div>';
  117. };
  118. img.addEventListener('load', () => {
  119. redditCarousel_recalcDimensions();
  120. redditCarousel_updateArrowVisibility();
  121. updateArrowPositions();
  122. });
  123. slide.appendChild(img);
  124. slideContainer.appendChild(slide);
  125. });
  126. const extraCounterWrapper = document.createElement('div');
  127. extraCounterWrapper.innerHTML = `<div class="absolute inset-0 overflow-visible flex items-right justify-end">
  128. <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 ">
  129. <span class="flex items-center justify-center">
  130. <span class="extra-counter-text flex items-center gap-xs">1/${urls.length}</span>
  131. </span>
  132. </button>
  133. </div>`;
  134. carousel.appendChild(extraCounterWrapper);
  135. let redditCarousel_currentIndex = 0, redditCarousel_currentOffset = 0;
  136. const redditCarousel_recalcDimensions = () => {
  137. const containerWidth = slideContainer.clientWidth;
  138. redditCarousel_currentOffset = -redditCarousel_currentIndex * containerWidth;
  139. slideContainer.style.transform = `translateX(${redditCarousel_currentOffset}px)`;
  140. };
  141. const redditCarousel_updateCounter = () => {
  142. const newCounterText = `${redditCarousel_currentIndex + 1}/${urls.length}`;
  143. const extraCounterText = carousel.querySelector('.extra-counter-text');
  144. if (extraCounterText) {
  145. extraCounterText.textContent = newCounterText;
  146. }
  147. };
  148. const redditCarousel_goToSlide = index => {
  149. index = Math.max(0, Math.min(index, urls.length - 1));
  150. const containerWidth = slideContainer.clientWidth;
  151. const startOffset = redditCarousel_currentOffset;
  152. const endOffset = -index * containerWidth;
  153. redditCarousel_animateTransition(slideContainer, startOffset, endOffset, 125, () => {
  154. redditCarousel_currentOffset = endOffset;
  155. redditCarousel_currentIndex = index;
  156. redditCarousel_updateCounter();
  157. redditCarousel_updateArrowVisibility();
  158. updateArrowPositions();
  159. });
  160. };
  161. let redditCarousel_leftArrow, redditCarousel_rightArrow;
  162. if (urls.length > 1) {
  163. const redditCarousel_createArrow = dir => {
  164. const btn = document.createElement('button');
  165. btn.classList.add('reddit-carousel-arrow', dir);
  166. btn.addEventListener('click', e => {
  167. e.stopPropagation();
  168. e.preventDefault();
  169. });
  170. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  171. svg.setAttribute("viewBox", "0 0 20 20");
  172. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  173. path.setAttribute("d", dir === 'left'
  174. ? "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"
  175. : "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"
  176. );
  177. svg.appendChild(path);
  178. btn.appendChild(svg);
  179. return btn;
  180. };
  181. redditCarousel_leftArrow = redditCarousel_createArrow('left');
  182. redditCarousel_leftArrow.addEventListener('click', () => {
  183. redditCarousel_goToSlide(redditCarousel_currentIndex - 1);
  184. });
  185. carousel.appendChild(redditCarousel_leftArrow);
  186. redditCarousel_rightArrow = redditCarousel_createArrow('right');
  187. redditCarousel_rightArrow.addEventListener('click', () => {
  188. redditCarousel_goToSlide(redditCarousel_currentIndex + 1);
  189. });
  190. carousel.appendChild(redditCarousel_rightArrow);
  191. }
  192. const redditCarousel_updateArrowVisibility = () => {
  193. if (urls.length <= 1) return;
  194. if (redditCarousel_currentIndex === 0) {
  195. redditCarousel_leftArrow.style.display = 'none';
  196. redditCarousel_rightArrow.style.display = 'flex';
  197. } else if (redditCarousel_currentIndex === urls.length - 1) {
  198. redditCarousel_leftArrow.style.display = 'flex';
  199. redditCarousel_rightArrow.style.display = 'none';
  200. } else {
  201. redditCarousel_leftArrow.style.display = 'flex';
  202. redditCarousel_rightArrow.style.display = 'flex';
  203. }
  204. };
  205. const updateArrowPositions = () => {
  206. requestAnimationFrame(() => {
  207. const rect = carousel.getBoundingClientRect();
  208. const arrowTop = (rect.height - 30) / 2;
  209. if (redditCarousel_leftArrow) {
  210. redditCarousel_leftArrow.style.top = arrowTop + 'px';
  211. }
  212. if (redditCarousel_rightArrow) {
  213. redditCarousel_rightArrow.style.top = arrowTop + 'px';
  214. }
  215. });
  216. };
  217. updateArrowPositions();
  218. let redditCarousel_resizeTimeout;
  219. window.addEventListener('resize', () => {
  220. clearTimeout(redditCarousel_resizeTimeout);
  221. redditCarousel_resizeTimeout = setTimeout(() => {
  222. redditCarousel_recalcDimensions();
  223. redditCarousel_updateArrowVisibility();
  224. updateArrowPositions();
  225. }, 100);
  226. });
  227. redditCarousel_updateArrowVisibility();
  228. return carousel;
  229. };
  230.  
  231. const redditCarousel_fetchAndProcessGallery = (postURL, container) => {
  232. GM.xmlHttpRequest({
  233. url: `${postURL}.json`,
  234. method: 'GET',
  235. onload: response => {
  236. if (response.status >= 200 && response.status < 300) {
  237. try {
  238. const jsonData = JSON.parse(response.responseText);
  239. const postData = jsonData[0]?.data?.children[0]?.data;
  240. if (!postData?.gallery_data) return;
  241. const { items } = postData.gallery_data;
  242. const mediaMeta = postData.media_metadata;
  243. const urls = items.reduce((acc, item) => {
  244. const meta = mediaMeta[item.media_id];
  245. if (meta && meta.id && meta.m) {
  246. let ext = "jpg";
  247. if (meta.m.includes("png")) ext = "png";
  248. else if (meta.m.includes("gif")) ext = "gif";
  249. else if (meta.m.includes("webp")) ext = "webp";
  250. acc.push(`https://i.redd.it/${meta.id}.${ext}`);
  251. }
  252. return acc;
  253. }, []);
  254. const carousel = redditCarousel_createCarousel(urls);
  255. if (carousel) {
  256. container.innerHTML = '';
  257. container.appendChild(carousel);
  258. const parentLink = container.closest('a');
  259. if (parentLink) {
  260. parentLink.addEventListener('click', e => {
  261. e.stopPropagation();
  262. e.preventDefault();
  263. });
  264. }
  265. }
  266. } catch (error) {
  267. console.error("JSON parse error:", error);
  268. }
  269. }
  270. },
  271. onerror: err => console.error("Request failed:", err)
  272. });
  273. };
  274.  
  275. const redditCarousel_galleryObserver = new IntersectionObserver((entries, observer) => {
  276. entries.forEach(entry => {
  277. if (entry.isIntersecting) {
  278. const container = entry.target;
  279. if (!container.hasAttribute('data-gallery-intersected')) {
  280. container.setAttribute('data-gallery-intersected', 'true');
  281. const postUnit = container.closest('div[data-id="search-media-post-unit"]');
  282. const postLink = postUnit?.querySelector('a.no-underline');
  283. if (postLink?.href) {
  284. redditCarousel_fetchAndProcessGallery(postLink.href, container);
  285. }
  286. }
  287. observer.unobserve(container);
  288. }
  289. });
  290. }, { threshold: 0.1 });
  291.  
  292. const redditCarousel_processSearchResults = () => {
  293. document.querySelectorAll('div[data-id="search-media-post-unit"]').forEach(post => {
  294. if (post.hasAttribute('data-gallery-checked')) return;
  295. post.setAttribute('data-gallery-checked', 'true');
  296. const indicator = post.querySelector('div.absolute.inset-0.overflow-visible.flex.items-right.justify-end button span');
  297. if (indicator?.textContent.includes('/')) {
  298. const container = post.querySelector('shreddit-aspect-ratio');
  299. if (container) redditCarousel_galleryObserver.observe(container);
  300. }
  301. });
  302. };
  303.  
  304. let redditCarousel_ticking = false;
  305. window.addEventListener('scroll', () => {
  306. if (!redditCarousel_ticking) {
  307. requestAnimationFrame(() => {
  308. redditCarousel_processSearchResults();
  309. redditCarousel_ticking = false;
  310. });
  311. redditCarousel_ticking = true;
  312. }
  313. });
  314. window.addEventListener('load', redditCarousel_processSearchResults);
  315. })();