8chan Unspoiler Thumbnails on Mouse Hover

Pre-sizes spoiler images to thumbnail dimensions and shows thumbnail on hover on 8chan.

目前为 2025-04-22 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name 8chan Unspoiler Thumbnails on Mouse Hover
  3. // @namespace sneed
  4. // @version 0.5.1
  5. // @description Pre-sizes spoiler images to thumbnail dimensions and shows thumbnail on hover on 8chan.
  6. // @author Gemini 2.5
  7. // @license MIT
  8. // @match https://8chan.moe/*/res/*.html*
  9. // @match https://8chan.se/*/res/*.html*
  10. // ==/UserScript==
  11.  
  12. (function() {
  13. 'use strict';
  14.  
  15. // Define the selector for both types of spoiler images
  16. // This matches the old /spoiler.png OR any image whose src ends with /custom.spoiler
  17. const spoilerImgSelector = 'img[src="/spoiler.png"], img[src$="/custom.spoiler"]';
  18.  
  19. // Function to extract the hash from a FoolFuuka image URL
  20. // Expects URL like /path/to/media/HASH.EXT
  21. function getHashFromImageUrl(imageUrl) {
  22. if (!imageUrl) return null;
  23. const parts = imageUrl.split('/');
  24. const filename = parts.pop(); // Get HASH.EXT
  25. if (!filename) return null;
  26. const hash = filename.split('.')[0]; // Get HASH (assuming no dots in hash)
  27. return hash || null;
  28. }
  29.  
  30. // Function to construct the thumbnail URL
  31. // Assumes thumbnail is in the same directory as the full image with 't_' prefix and no extension
  32. function getThumbnailUrl(fullImageUrl, hash) {
  33. if (!fullImageUrl || !hash) return null;
  34. const parts = fullImageUrl.split('/');
  35. parts.pop(); // Remove the filename (HASH.EXT)
  36. const basePath = parts.join('/') + '/'; // Rejoin path parts and add trailing slash
  37. return basePath + 't_' + hash; // Use the t_HASH format
  38. }
  39.  
  40. // --- Dimension Setting Logic ---
  41.  
  42. // Function to load the thumbnail invisibly and set the spoiler image's dimensions
  43. function setSpoilerDimensionsFromThumbnail(imgLink) {
  44. // Find the specific spoiler image within this link using the updated selector
  45. const spoilerImg = imgLink.querySelector(spoilerImgSelector);
  46.  
  47. // Only proceed if we have a spoiler image and its dimensions haven't already been set by this script
  48. // We use a data attribute on the spoiler image itself to track if dimensions were attempted.
  49. if (!spoilerImg || spoilerImg.dataset.dimensionsSet) {
  50. return;
  51. }
  52.  
  53. const fullImageUrl = imgLink.href;
  54. const hash = getHashFromImageUrl(fullImageUrl);
  55. if (!hash) return;
  56.  
  57. const thumbnailUrl = getThumbnailUrl(fullImageUrl, hash);
  58. if (!thumbnailUrl) {
  59. // Mark as dimensions attempted, but failed due to URL issues
  60. spoilerImg.dataset.dimensionsSet = 'failed_url';
  61. return;
  62. }
  63.  
  64. // Create a temporary image element to load the thumbnail and get its dimensions
  65. const tempImg = new Image(); // Use new Image() which is efficient for this
  66. tempImg.style.display = 'none'; // Hide it
  67. tempImg.style.position = 'absolute'; // Position it off-screen or just hidden
  68. tempImg.style.left = '-9999px';
  69. tempImg.style.top = '-9999px';
  70.  
  71. // Mark the spoiler image now as dimensions are being attempted.
  72. spoilerImg.dataset.dimensionsSet = 'attempting';
  73.  
  74. tempImg.addEventListener('load', function() {
  75. // Set the dimensions of the spoiler image if loaded successfully
  76. if (this.naturalWidth > 0 && this.naturalHeight > 0) {
  77. spoilerImg.width = this.naturalWidth;
  78. spoilerImg.height = this.naturalHeight;
  79. // Mark as dimensions successfully set
  80. spoilerImg.dataset.dimensionsSet = 'success';
  81. } else {
  82. // Mark as failed if dimensions were unexpectedly zero
  83. spoilerImg.dataset.dimensionsSet = 'failed_zero_dim';
  84. console.warn(`[SpoilerThumbnailHover] Thumbnail loaded but reported zero dimensions: ${thumbnailUrl}`);
  85. }
  86. // Clean up the temporary image regardless of success/failure after load
  87. if (this.parentNode) {
  88. this.parentNode.removeChild(this);
  89. }
  90. });
  91.  
  92. tempImg.addEventListener('error', function() {
  93. console.warn(`[SpoilerThumbnailHover] Failed to load thumbnail: ${thumbnailUrl}`);
  94. // Mark as failed on error
  95. spoilerImg.dataset.dimensionsSet = 'failed_error';
  96. // Clean up the temporary image
  97. if (this.parentNode) {
  98. this.parentNode.removeChild(this);
  99. }
  100. // Don't set dimensions if load failed. The spoiler keeps its default size.
  101. });
  102.  
  103. // Append the temporary image to the body to start loading
  104. // This must happen *after* setting up event listeners.
  105. document.body.appendChild(tempImg);
  106.  
  107. // Start loading the image by setting the src
  108. tempImg.src = thumbnailUrl;
  109. }
  110.  
  111.  
  112. // --- Hover Event Handlers ---
  113.  
  114. function handleLinkMouseEnter() {
  115. const imgLink = this; // The .imgLink element
  116. // Find the specific spoiler image within this link using the updated selector
  117. const spoilerImg = imgLink.querySelector(spoilerImgSelector);
  118. const existingHoverThumbnail = imgLink.querySelector('img.hoverThumbnail');
  119.  
  120. // Only proceed if there's a visible spoiler image (of either type) and no hover thumbnail already exists
  121. if (!spoilerImg || spoilerImg.style.display === 'none' || existingHoverThumbnail) {
  122. return;
  123. }
  124.  
  125. // Ensure dimensions were at least attempted before proceeding with hover effect
  126. // if (spoilerImg.dataset.dimensionsSet === 'attempting') {
  127. // // Thumbnail loading is still in progress, maybe wait or do nothing?
  128. // // Doing nothing for now is simplest. The spoiler stays default size until load finishes.
  129. // return;
  130. // }
  131. // Removed the check above because we want the hover to work even if dimensions failed or are pending.
  132. // The thumbnail creation below will just use the *current* size of the spoiler img.
  133.  
  134. const fullImageUrl = imgLink.href; // Use href of the imgLink for the full image URL
  135. const hash = getHashFromImageUrl(fullImageUrl);
  136. if (!hash) return;
  137.  
  138. const thumbnailUrl = getThumbnailUrl(fullImageUrl, hash);
  139. if (!thumbnailUrl) return;
  140.  
  141. // Create the thumbnail image element
  142. const hoverThumbnail = document.createElement('img');
  143. hoverThumbnail.src = thumbnailUrl;
  144. hoverThumbnail.classList.add('hoverThumbnail'); // Add a class to identify our element
  145.  
  146. // Set thumbnail dimensions to match the current spoiler image size.
  147. // The spoiler image should now have the correct size set by setSpoilerDimensionsFromThumbnail.
  148. // If dimension setting failed or hasn't completed, it will use the default spoiler size.
  149. if (spoilerImg.width > 0 && spoilerImg.height > 0) {
  150. hoverThumbnail.width = spoilerImg.width;
  151. hoverThumbnail.height = spoilerImg.height;
  152. }
  153. // Note: If the thumbnail loads *after* the mouse enters but before mouse leaves,
  154. // the dimensions might be updated on the spoiler, but the hover thumbnail already created
  155. // won't update dynamically. This is an acceptable minor edge case.
  156.  
  157. // Insert the thumbnail right before the spoiler image
  158. imgLink.insertBefore(hoverThumbnail, spoilerImg);
  159.  
  160. // Hide the original spoiler image
  161. spoilerImg.style.display = 'none';
  162. }
  163.  
  164. function handleLinkMouseLeave() {
  165. const imgLink = this; // The .imgLink element
  166. // Find the specific spoiler image within this link using the updated selector
  167. const spoilerImg = imgLink.querySelector(spoilerImgSelector);
  168. const hoverThumbnail = imgLink.querySelector('img.hoverThumbnail');
  169.  
  170. // If our hover thumbnail exists, remove it
  171. if (hoverThumbnail) {
  172. hoverThumbnail.remove();
  173. }
  174.  
  175. // Check if the board's full image expansion is visible
  176. // Selects any img within the link that is NOT a spoiler image type, and check if it's visible.
  177. const otherImages = imgLink.querySelectorAll(`img:not(${spoilerImgSelector})`);
  178. let isOtherImageVisible = false;
  179. for(const img of otherImages) {
  180. // Check if the image is not hidden by display: none or visibility: hidden etc.
  181. // offsetParent is null if the element or its parent is display: none
  182. // Checking style.display is more direct for FoolFuuka's toggle
  183. if (img.style.display !== 'none') {
  184. isOtherImageVisible = true;
  185. break;
  186. }
  187. }
  188.  
  189. // Show the original spoiler image again IF
  190. // 1. It exists and is still one of the spoiler image types
  191. // 2. It's currently hidden (style.display === 'none') - implies our script or board script hid it
  192. // 3. The board's expanded image is NOT currently visible.
  193. // 4. Add a check to ensure the spoiler image is still in the DOM hierarchy of the link.
  194. if (spoilerImg && imgLink.contains(spoilerImg) && spoilerImg.matches(spoilerImgSelector) && spoilerImg.style.display === 'none' && !isOtherImageVisible) {
  195. spoilerImg.style.display = ''; // Reset to default display
  196. }
  197. }
  198.  
  199.  
  200. // Function to process an individual imgLink element
  201. function processImgLink(imgLink) {
  202. // Prevent processing multiple times
  203. if (imgLink.dataset.spoilerHoverProcessed) {
  204. return;
  205. }
  206.  
  207. // Find the specific spoiler image within this link using the updated selector
  208. const spoilerImg = imgLink.querySelector(spoilerImgSelector);
  209.  
  210. // Only process if this link contains a spoiler image (of either type)
  211. if (!spoilerImg) {
  212. return;
  213. }
  214.  
  215. imgLink.dataset.spoilerHoverProcessed = 'true'; // Mark element as processed
  216.  
  217. // 1. Attempt to set spoiler dimensions based on thumbnail as soon as possible
  218. // This happens asynchronously via the temp image loader.
  219. setSpoilerDimensionsFromThumbnail(imgLink);
  220.  
  221. // 2. Attach the hover listeners for showing the thumbnail on hover
  222. // These listeners rely on the spoiler image potentially having updated dimensions
  223. // by the time the mouse enters.
  224. imgLink.addEventListener('mouseenter', handleLinkMouseEnter);
  225. imgLink.addEventListener('mouseleave', handleLinkMouseLeave);
  226.  
  227. // Optional: Handle clicks on the link to ensure the hover thumbnail is removed
  228. // immediately if the user clicks to expand the image.
  229. // However, the handleLinkMouseLeave check for isOtherImageVisible should handle this
  230. // when the mouse leaves after clicking/expanding. Let's stick to just mouse events for now.
  231. }
  232.  
  233. // Function to find all imgLink elements within a container and process them
  234. function processContainer(container) {
  235. // Select imgLink elements
  236. const imgLinks = container.querySelectorAll('.imgLink');
  237. imgLinks.forEach(processImgLink); // Process each found imgLink
  238. }
  239.  
  240. // Use a MutationObserver to handle new nodes being added to the DOM (e.g., infinite scroll)
  241. const observer = new MutationObserver(function(mutations) {
  242. mutations.forEach(function(mutation) {
  243. if (mutation.addedNodes && mutation.addedNodes.length > 0) {
  244. mutation.addedNodes.forEach(function(node) {
  245. // nodeType 1 is Element
  246. if (node.nodeType === Node.ELEMENT_NODE) {
  247. // If the added node is an imgLink (potentially with a spoiler)
  248. // Or if it's a container that might contain imgLinks (like posts, board content)
  249. if (node.matches('.imgLink')) {
  250. processImgLink(node); // Process just this specific link
  251. } else {
  252. // Select all imgLink elements within the added node's subtree
  253. processContainer(node);
  254. }
  255. }
  256. });
  257. }
  258. });
  259. });
  260.  
  261. // Configuration for the observer:
  262. // - childList: true means observe direct children being added/removed
  263. // - subtree: true means observe changes in the entire subtree
  264. observer.observe(document.body, { childList: true, subtree: true });
  265.  
  266. // Process imgLink elements that are already present in the DOM when the script runs
  267. processContainer(document);
  268.  
  269. })();