8chan Toggle All Media per Post

Adds a [+]/[-] button to expand/collapse all media in a single post on 8chan.moe/se.

  1. // ==UserScript==
  2. // @name 8chan Toggle All Media per Post
  3. // @namespace sneed
  4. // @version 1.5
  5. // @description Adds a [+]/[-] button to expand/collapse all media in a single post on 8chan.moe/se.
  6. // @author Gemini 2.5
  7. // @license MIT
  8. // @match https://8chan.moe/*/res/*.html*
  9. // @match https://8chan.se/*/res/*.html*
  10. // @grant none
  11. // @run-at document-idle
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. const EXPAND_TEXT = '[+]';
  18. const COLLAPSE_TEXT = '[-]';
  19. const BUTTON_CLASS = 'toggle-all-media-btn'; // Class for the button
  20.  
  21. /**
  22. * Cleans up extra visible a.hideLink elements in a media cell after collapse.
  23. * @param {HTMLElement} uploadCell - The figure.uploadCell element.
  24. */
  25. function cleanupExtraHideLinks(uploadCell) {
  26. // We only expect one *functional* hide link per audio/video player when expanded.
  27. // When collapsed, there should ideally be zero *visible* hide links.
  28. // This function targets cells that are NOT expanded and might have leftover links.
  29. if (uploadCell.classList.contains('expandedCell')) {
  30. return; // Only clean up collapsed cells
  31. }
  32.  
  33. const hideLinks = uploadCell.querySelectorAll('a.hideLink');
  34.  
  35. if (hideLinks.length > 1) {
  36. // console.log(`Cleanup: Found ${hideLinks.length} hide links in a non-expanded cell. Hiding extras.`);
  37. // Keep the first one potentially, or just hide all visible extras
  38. // Let's hide all except the first one found, as the first one might be the "correct" one if any interaction happened.
  39. for (let i = 1; i < hideLinks.length; i++) {
  40. hideLinks[i].style.display = 'none';
  41. }
  42. // Even the first one shouldn't be visible if the cell isn't expanded, based on normal behavior.
  43. // Let's ensure all hide links are hidden if the cell is not expanded.
  44. hideLinks.forEach(link => link.style.display = 'none');
  45. } else if (hideLinks.length === 1) {
  46. // If there's exactly one hide link, ensure it's hidden if the cell is not expanded
  47. hideLinks[0].style.display = 'none';
  48. }
  49. // If length is 0 or 1 (and handled above), nothing more needed.
  50. }
  51.  
  52.  
  53. /**
  54. * Adds a toggle button to expand/collapse all media in a post if it has multiple uploads.
  55. * @param {HTMLElement} postElement - The post element (.postCell or .opCell).
  56. */
  57. function addExpandToggleButton(postElement) {
  58. const panelUploads = postElement.querySelector('.panelUploads');
  59.  
  60. if (!panelUploads || postElement.querySelector(`.${BUTTON_CLASS}`)) {
  61. return;
  62. }
  63.  
  64. const uploadCells = panelUploads.querySelectorAll('.uploadCell');
  65. if (uploadCells.length <= 1) {
  66. return;
  67. }
  68.  
  69. const button = document.createElement('span');
  70. button.textContent = EXPAND_TEXT;
  71. button.title = 'Toggle expand/collapse all media in this post';
  72. button.classList.add(BUTTON_CLASS);
  73. button.dataset.state = 'collapsed'; // Initial state assumes things start collapsed
  74.  
  75. // button.style.display = 'block';
  76. button.style.marginBottom = '5px';
  77. button.style.cursor = 'pointer';
  78. button.style.fontSize = '0.9em';
  79. button.style.fontWeight = 'bold';
  80. button.style.color = 'var(--link-color, blue)';
  81. button.style.userSelect = 'none';
  82.  
  83. button.addEventListener('click', (event) => {
  84. event.preventDefault();
  85. event.stopPropagation();
  86.  
  87. const currentPost = event.target.closest('.postCell, .opCell');
  88. if (!currentPost) return;
  89.  
  90. const currentPanelUploads = currentPost.querySelector('.panelUploads');
  91. if (!currentPanelUploads) return;
  92.  
  93. const mediaItems = currentPanelUploads.querySelectorAll('.uploadCell');
  94. const currentState = button.dataset.state;
  95.  
  96. if (currentState === 'collapsed') {
  97. // --- Action: Expand currently collapsed items ---
  98. mediaItems.forEach(cell => {
  99. // Find items that are NOT expanded
  100. if (!cell.classList.contains('expandedCell')) {
  101. const link = cell.querySelector('a.imgLink');
  102. if (link) {
  103. // console.log('Expanding:', link.href);
  104. link.click(); // Click the main link to trigger expansion
  105. }
  106. }
  107. });
  108. // Update state AFTER action
  109. button.dataset.state = 'expanded';
  110. button.textContent = COLLAPSE_TEXT;
  111.  
  112. } else { // currentState === 'expanded'
  113. // --- Action: Collapse currently expanded items ---
  114. mediaItems.forEach(cell => {
  115. // Find items that ARE expanded
  116. if (cell.classList.contains('expandedCell')) {
  117. // For expanded audio/video, the collapse button is a.hideLink
  118. const hideLink = cell.querySelector('a.hideLink');
  119. if (hideLink) {
  120. // console.log('Collapsing (via hideLink):', cell.querySelector('a.imgLink')?.href);
  121. hideLink.click(); // Click the hide link
  122. } else {
  123. // For expanded images, the collapse action is clicking a.imgLink again
  124. const mainLink = cell.querySelector('a.imgLink');
  125. if (mainLink) {
  126. // console.log('Collapsing (via imgLink):', mainLink.href);
  127. mainLink.click();
  128. }
  129. }
  130. }
  131. });
  132.  
  133. // Update state AFTER action
  134. button.dataset.state = 'collapsed';
  135. button.textContent = EXPAND_TEXT;
  136.  
  137. // --- Cleanup: Hide any extra, visible hide links after collapse ---
  138. // Add a small delay to allow the native script's collapse animation/DOM changes to finish
  139. // before cleaning up.
  140. setTimeout(() => {
  141. mediaItems.forEach(cell => {
  142. cleanupExtraHideLinks(cell);
  143. });
  144. }, 50); // 50ms delay should be sufficient
  145. }
  146. });
  147.  
  148. const firstUploadCell = panelUploads.querySelector('.uploadCell');
  149. if (firstUploadCell) {
  150. panelUploads.insertBefore(button, firstUploadCell);
  151. } else {
  152. panelUploads.appendChild(button);
  153. }
  154. }
  155.  
  156. /**
  157. * Observes the main post container for newly added posts and adds buttons to them.
  158. */
  159. function observeNewPosts() {
  160. const targetNode = document.querySelector('#divThreads .divPosts');
  161. if (!targetNode) {
  162. console.warn('Toggle All Media: Could not find target node for MutationObserver.');
  163. return;
  164. }
  165.  
  166. const config = { childList: true };
  167.  
  168. const callback = function(mutationsList, observer) {
  169. for(const mutation of mutationsList) {
  170. if (mutation.type === 'childList') {
  171. mutation.addedNodes.forEach(node => {
  172. if (node.nodeType === Node.ELEMENT_NODE) {
  173. if (node.matches('.postCell, .opCell')) {
  174. addExpandToggleButton(node);
  175. } else {
  176. // Check for posts potentially nested within the added node (e.g. in a wrapper)
  177. node.querySelectorAll('.postCell, .opCell').forEach(addExpandToggleButton);
  178. }
  179. }
  180. });
  181. }
  182. }
  183. };
  184.  
  185. const observer = new MutationObserver(callback);
  186. observer.observe(targetNode, config);
  187. }
  188.  
  189. // --- Main Execution ---
  190. document.querySelectorAll('.postCell, .opCell').forEach(addExpandToggleButton);
  191. requestAnimationFrame(observeNewPosts);
  192.  
  193. })();