8chan Collapsible Thread Chains (Toggle with Auto-Restore)

Make quote links collapsible with indented hierarchy, restore posts when toggled off

2025/04/20のページです。最新版はこちら。

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
  1. // ==UserScript==
  2. // @name 8chan Collapsible Thread Chains (Toggle with Auto-Restore)
  3. // @version 2.0.3
  4. // @description Make quote links collapsible with indented hierarchy, restore posts when toggled off
  5. // @match https://8chan.moe/*/res/*
  6. // @match https://8chan.se/*/res/*
  7. // @grant GM_addStyle
  8. // @grant GM.addStyle
  9. // @license MIT
  10. // @namespace https://greatest.deepsurf.us/users/1459581
  11. // ==/UserScript==
  12.  
  13. (function() {
  14. 'use strict';
  15.  
  16. GM_addStyle(`
  17. .collapsible-container {
  18. margin-left: 20px;
  19. padding-left: 5px;
  20. margin-top: 8px;
  21. border-left: 1px solid #474b53;
  22. border-top: 1px solid #474b53;
  23. }
  24. .post-content.collapsed {
  25. display: none;
  26. }
  27. .altBacklinks {
  28. display: none !important;
  29. }
  30. .postCell.post-content {
  31. border: none !important;
  32. }
  33. .innerPost {
  34. width: auto;
  35. max-width: none !important;
  36. }
  37. .moved-post {
  38. position: relative;
  39. opacity: 0.9;
  40. }
  41. .linkQuote.toggled, .panelBacklinks a.toggled {
  42. color: #9a5;
  43. }
  44. .post-placeholder {
  45. display: none;
  46. padding: 5px;
  47. background: rgba(50, 50, 50, 0.3);
  48. border: 1px dashed #474b53;
  49. font-style: italic;
  50. color: #8c8c8c;
  51. text-align: center;
  52. margin: 5px 0;
  53. }
  54. .placeholder-visible {
  55. display: block;
  56. }
  57. `);
  58.  
  59. const movedPosts = new Map();
  60. const linkContainers = new Map();
  61. const originalPosts = new Map();
  62.  
  63. document.body.addEventListener('click', function(event) {
  64. const target = event.target;
  65.  
  66. if (target.classList.contains('linkQuote') && event.ctrlKey) {
  67. const postId = target.href.match(/#q?(\d+)/)?.[1];
  68. if (postId && typeof qr !== 'undefined' && qr.showQr) {
  69. qr.showQr(postId);
  70. event.preventDefault();
  71. return;
  72. }
  73. }
  74.  
  75. // Handle restore post link clicks
  76. if (target.classList.contains('restore-post-link')) {
  77. event.preventDefault();
  78. const postId = target.dataset.postId;
  79. if (postId && movedPosts.has(postId)) {
  80. restorePost(postId);
  81. }
  82. return;
  83. }
  84.  
  85. // Handle backlink panel direct restoration
  86. if ((target.parentNode && target.parentNode.classList.contains('panelBacklinks')) && !event.ctrlKey) {
  87. const link = target.closest('a');
  88. if (!link) return;
  89.  
  90. const rawHash = link.hash.includes('?') ? link.hash.split('?')[0] : link.hash;
  91. const targetId = rawHash.substring(1).replace(/^q/, '');
  92.  
  93. if (movedPosts.has(targetId)) {
  94. event.preventDefault();
  95. restorePost(targetId);
  96. return;
  97. }
  98. }
  99.  
  100. // Modified condition: Only process .panelBacklinks
  101. if ((target.parentNode && target.parentNode.classList.contains('panelBacklinks'))) {
  102. event.preventDefault();
  103.  
  104. const link = target.closest('a');
  105. if (!link) return;
  106.  
  107. const rawHash = link.hash.includes('?') ? link.hash.split('?')[0] : link.hash;
  108. const targetId = rawHash.substring(1).replace(/^q/, '');
  109.  
  110. if (linkContainers.has(link)) {
  111. const container = linkContainers.get(link);
  112. const content = container.querySelector('.post-content');
  113.  
  114. if (content) {
  115. const wasCollapsed = content.classList.contains('collapsed');
  116. content.classList.toggle('collapsed');
  117. link.classList.toggle('toggled');
  118.  
  119. if (!wasCollapsed && movedPosts.has(targetId)) {
  120. const postData = movedPosts.get(targetId);
  121. postData.links.delete(link);
  122. if (postData.links.size === 0) {
  123. restorePost(targetId);
  124. }
  125. }
  126. }
  127. return;
  128. }
  129.  
  130. if (!originalPosts.has(targetId)) {
  131. let targetPost = document.getElementById(targetId);
  132. if (!targetPost) return;
  133. originalPosts.set(targetId, targetPost);
  134. }
  135.  
  136. let postToUse;
  137.  
  138. if (movedPosts.has(targetId)) {
  139. postToUse = movedPosts.get(targetId).element;
  140. } else {
  141. postToUse = document.getElementById(targetId) || originalPosts.get(targetId);
  142. if (!postToUse) return;
  143. }
  144.  
  145. const level = link.closest('.collapsible-container')?.dataset.level || 0;
  146. const container = document.createElement('div');
  147. container.className = 'collapsible-container';
  148. container.dataset.level = parseInt(level) + 1;
  149.  
  150. movePostToContainer(targetId, postToUse, container, link);
  151.  
  152. const postContainer = link.closest('.innerPost');
  153. if (postContainer) {
  154. postContainer.appendChild(container);
  155. } else {
  156. link.parentNode.insertBefore(container, link.nextSibling);
  157. }
  158.  
  159. linkContainers.set(link, container);
  160. link.classList.add('toggled');
  161. }
  162. });
  163.  
  164. function movePostToContainer(postId, postToUse, container, link) {
  165. if (movedPosts.has(postId)) {
  166. const postData = movedPosts.get(postId);
  167. const lightClone = postData.element.cloneNode(true);
  168. lightClone.classList.add('post-content', 'moved-post');
  169. lightClone.setAttribute('data-original-id', postId);
  170. container.appendChild(lightClone);
  171. postData.links.add(link);
  172. return;
  173. }
  174.  
  175. if (!document.getElementById(postId) && originalPosts.has(postId)) {
  176. const originalPost = originalPosts.get(postId);
  177. const clone = originalPost.cloneNode(true);
  178. clone.classList.add('post-content', 'moved-post');
  179. clone.setAttribute('data-original-id', postId);
  180. container.appendChild(clone);
  181. const placeholder = document.createElement('div');
  182. placeholder.className = 'post-placeholder';
  183. movedPosts.set(postId, {
  184. element: clone,
  185. placeholder: placeholder,
  186. links: new Set([link])
  187. });
  188. return;
  189. }
  190.  
  191. const placeholder = document.createElement('div');
  192. placeholder.className = 'post-placeholder placeholder-visible';
  193. placeholder.innerHTML = `Post moved <a href="#" class="restore-post-link" data-post-id="${postId}">Restore</a>`;
  194. postToUse.parentNode.insertBefore(placeholder, postToUse);
  195. postToUse.classList.add('post-content', 'moved-post');
  196. postToUse.setAttribute('data-original-id', postId);
  197. container.appendChild(postToUse);
  198. movedPosts.set(postId, {
  199. element: postToUse,
  200. placeholder: placeholder,
  201. links: new Set([link])
  202. });
  203. }
  204.  
  205. function restorePost(postId) {
  206. if (!movedPosts.has(postId)) return;
  207.  
  208. const {element, placeholder, links} = movedPosts.get(postId);
  209.  
  210. links.forEach(link => {
  211. if (linkContainers.has(link)) {
  212. const container = linkContainers.get(link);
  213. container.remove();
  214. linkContainers.delete(link);
  215. link.classList.remove('toggled');
  216. }
  217. });
  218.  
  219. document.querySelectorAll(`.moved-post[data-original-id="${postId}"]`).forEach(instance => {
  220. if (instance !== element) {
  221. instance.remove();
  222. }
  223. });
  224.  
  225. if (placeholder.parentNode) {
  226. placeholder.parentNode.insertBefore(element, placeholder);
  227. placeholder.remove();
  228. }
  229.  
  230. element.classList.remove('post-content', 'moved-post');
  231. element.removeAttribute('data-original-id'); // Remove the data-original-id attribute
  232.  
  233. movedPosts.delete(postId);
  234.  
  235. if (!originalPosts.has(postId)) {
  236. originalPosts.set(postId, element);
  237. }
  238. }
  239.  
  240. function cleanupBacklinks() {
  241. document.querySelectorAll('span.panelBacklinks a').forEach(link => {
  242. const href = link.getAttribute('href');
  243. if (href?.includes('#')) {
  244. link.href = `#${href.split('#')[1].split('?')[0]}`;
  245. }
  246. });
  247. }
  248.  
  249. const observer = new MutationObserver((mutations) => {
  250. let shouldProcess = false;
  251.  
  252. for (const mutation of mutations) {
  253. if (mutation.addedNodes.length) {
  254. for (const node of mutation.addedNodes) {
  255. if (node.nodeType === 1 &&
  256. (node.classList?.contains('post') ||
  257. node.querySelector?.('.post, .linkQuote, .panelBacklinks'))) {
  258. shouldProcess = true;
  259. break;
  260. }
  261. }
  262. if (shouldProcess) break;
  263. }
  264. }
  265.  
  266. if (shouldProcess) {
  267. cleanupBacklinks();
  268. }
  269. });
  270.  
  271. const threadContainer = document.querySelector('.thread');
  272. if (threadContainer) {
  273. observer.observe(threadContainer, { childList: true, subtree: true });
  274. } else {
  275. observer.observe(document.body, { childList: true, subtree: false });
  276. }
  277.  
  278. cleanupBacklinks();
  279. })();