Fullchan X

16/04/2025, 18:06:52

目前为 2025-04-18 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Fullchan X
  3. // @namespace Violentmonkey Scripts
  4. // @match https://8chan.moe/*/res/*.html*
  5. // @match https://8chan.se/*/res/*.html*
  6. // @grant none
  7. // @version 1.4
  8. // @author vfyxe
  9. // @description 16/04/2025, 18:06:52
  10. // ==/UserScript==
  11.  
  12. if (!document.querySelector('.divPosts')) return;
  13.  
  14. class fullChanX extends HTMLElement {
  15. constructor() {
  16. super();
  17. this.enableNestedQuotes = true;
  18. }
  19.  
  20. init() {
  21. this.quickReply = document.querySelector('#quick-reply');
  22. this.qrbody = document.querySelector('#qrbody');
  23. this.threadParent = document.querySelector('#divThreads');
  24. this.gallery = document.querySelector('fullchan-x-gallery');
  25. this.galleryButton = this.querySelector('#fcx-gallery-btn');
  26. this.threadId = this.threadParent.querySelector('.opCell').id;
  27. this.thread = this.threadParent.querySelector('.divPosts');
  28. this.posts = [...this.thread.querySelectorAll('.postCell')];
  29. this.postOrder = 'default';
  30. this.postOrderSelect = this.querySelector('#thread-sort');
  31. this.myYousLabel = this.querySelector('.my-yous__label');
  32. this.yousContainer = this.querySelector('#my-yous');
  33. this.updateYous();
  34. this.observers();
  35. }
  36.  
  37. observers () {
  38. this.postOrderSelect.addEventListener('change', (event) => {
  39. this.postOrder = event.target.value;
  40. this.assignPostOrder();
  41. });
  42.  
  43. const observerCallback = (mutationsList, observer) => {
  44. for (const mutation of mutationsList) {
  45. if (mutation.type === 'childList') {
  46. this.posts = [...this.thread.querySelectorAll('.postCell')];
  47. if (this.postOrder !== 'default') this.assignPostOrder();
  48. this.updateYous();
  49. this.gallery.updateGalleryImages();
  50. }
  51. }
  52. };
  53.  
  54. const threadObserver = new MutationObserver(observerCallback);
  55. threadObserver.observe(this.thread, { childList: true, subtree: false });
  56.  
  57. if (this.enableNestedQuotes) {
  58. this.thread.addEventListener('click', event => {
  59. this.handleClick(event);
  60. });
  61. }
  62.  
  63. this.galleryButton.addEventListener('click', () => this.gallery.open());
  64. }
  65.  
  66. handleClick (event) {
  67. const clicked = event.target;
  68.  
  69. const post = clicked.closest('.innerPost') || clicked.closest('.innerOP');
  70. if (!post) return;
  71.  
  72. const isNested = !!post.closest('.innerNested');
  73. const nestQuote = clicked.closest('.quoteLink');
  74. const postMedia = clicked.closest('a[data-filemime]');
  75. const postId = clicked.closest('.linkQuote');
  76.  
  77. if (nestQuote) {
  78. event.preventDefault();
  79. this.nestQuote(nestQuote);
  80. } else if (postMedia && isNested) {
  81. this.handleMediaClick(event, postMedia);
  82. } else if (postId && isNested) {
  83. this.handleIdClick(postId);
  84. }
  85. }
  86.  
  87. handleMediaClick (event, postMedia) {
  88. if (postMedia.dataset.filemime === "video/webm") return;
  89. event.preventDefault();
  90. const imageSrc = `${postMedia.href}`;
  91. const imageEl = postMedia.querySelector('img');
  92. if (!postMedia.dataset.thumbSrc) postMedia.dataset.thumbSrc = `${imageEl.src}`;
  93.  
  94. const isExpanding = imageEl.src !== imageSrc;
  95.  
  96. if (isExpanding) {
  97. imageEl.src = imageSrc;
  98. imageEl.classList
  99. }
  100. imageEl.src = isExpanding ? imageSrc : postMedia.dataset.thumbSrc;
  101. imageEl.classList.toggle('imgExpanded', isExpanding);
  102. }
  103.  
  104. handleIdClick (postId) {
  105. const idNumber = '>>' + postId.textContent;
  106. this.quickReply.style.display = 'block';
  107. this.qrbody.value += idNumber + '\n';
  108. }
  109.  
  110. assignPostOrder () {
  111. const postOrderReplies = (post) => {
  112. const replyCount = post.querySelectorAll('.panelBacklinks a').length;
  113. post.style.order = 100 - replyCount;
  114. }
  115.  
  116. const postOrderCatbox = (post) => {
  117. const postContent = post.querySelector('.divMessage').textContent;
  118. const matches = postContent.match(/catbox\.moe/g);
  119. const catboxCount = matches ? matches.length : 0;
  120. post.style.order = 100 - catboxCount;
  121. }
  122.  
  123. if (this.postOrder === 'default') {
  124. this.thread.style.display = 'block';
  125. return;
  126. }
  127.  
  128. this.thread.style.display = 'flex';
  129.  
  130. if (this.postOrder === 'replies') {
  131. this.posts.forEach(post => postOrderReplies(post));
  132. } else if (this.postOrder === 'catbox') {
  133. this.posts.forEach(post => postOrderCatbox(post));
  134. }
  135. }
  136.  
  137. updateYous () {
  138. this.yous = this.posts.filter(post => post.querySelector('.quoteLink.you'));
  139. this.yousLinks = this.yous.map(you => {
  140. const youLink = document.createElement('a');
  141. youLink.textContent = '>>' + you.id;
  142. youLink.href = '#' + you.id;
  143. return youLink;
  144. })
  145.  
  146. let hasUnseenYous = false;
  147. this.setUnseenYous();
  148.  
  149. this.yousContainer.innerHTML = '';
  150. this.yousLinks.forEach(you => {
  151. const youId = you.textContent.replace('>>', '');
  152. if (!this.seenYous.includes(youId)) {
  153. you.classList.add('unseen');
  154. hasUnseenYous = true
  155. }
  156. this.yousContainer.appendChild(you)
  157. });
  158.  
  159. this.myYousLabel.classList.toggle('unseen', hasUnseenYous);
  160. document.title = hasUnseenYous
  161. ? document.title.startsWith('🔴 ') ? document.title : `🔴 ${document.title}`
  162. : document.title.replace(/^🔴 /, '');
  163. }
  164.  
  165. observeUnseenYou(you) {
  166. you.classList.add('observe-you');
  167.  
  168. const observer = new IntersectionObserver((entries, observer) => {
  169. entries.forEach(entry => {
  170. if (entry.isIntersecting) {
  171. const id = you.id;
  172. you.classList.remove('observe-you');
  173.  
  174. if (!this.seenYous.includes(id)) {
  175. this.seenYous.push(id);
  176. localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
  177. }
  178.  
  179. observer.unobserve(you);
  180. this.updateYous();
  181.  
  182. }
  183. });
  184. }, { rootMargin: '0px', threshold: 0.1 });
  185.  
  186. observer.observe(you);
  187. }
  188.  
  189. setUnseenYous() {
  190. this.seenKey = `${this.threadId}-seen-yous`;
  191. this.seenYous = JSON.parse(localStorage.getItem(this.seenKey));
  192.  
  193. if (!this.seenYous) {
  194. this.seenYous = [];
  195. localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
  196. }
  197.  
  198. this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id));
  199.  
  200. this.unseenYous.forEach(you => {
  201. if (!you.classList.contains('observe-you')) {
  202. this.observeUnseenYou(you);
  203. }
  204. });
  205. }
  206.  
  207. nestQuote(quoteLink) {
  208. const parentPostMessage = quoteLink.closest('.divMessage');
  209. const quoteId = quoteLink.href.split('#')[1];
  210. const quotePost = document.getElementById(quoteId);
  211. if (!quotePost) return;
  212.  
  213. const quotePostContent = quotePost.querySelector('.innerOP') || quotePost.querySelector('.innerPost');
  214. if (!quotePostContent) return;
  215.  
  216. const existing = parentPostMessage.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`);
  217. if (existing) {
  218. existing.remove();
  219. return;
  220. }
  221.  
  222. const wrapper = document.createElement('div');
  223. wrapper.classList.add('nestedPost');
  224. wrapper.setAttribute('data-quote-id', quoteId);
  225.  
  226. const clone = quotePostContent.cloneNode(true);
  227. clone.style.whiteSpace = 'unset';
  228. clone.classList.add('innerNested');
  229. wrapper.appendChild(clone);
  230.  
  231. parentPostMessage.appendChild(wrapper);
  232. }
  233. };
  234.  
  235. window.customElements.define('fullchan-x', fullChanX);
  236.  
  237.  
  238. class fullChanXGallery extends HTMLElement {
  239. constructor() {
  240. super();
  241. }
  242.  
  243. init() {
  244. this.fullchanX = document.querySelector('fullchan-x');
  245. this.imageContainer = this.querySelector('.gallery__images');
  246. this.mainImageContainer = this.querySelector('.gallery__main-image');
  247. this.mainImage = this.mainImageContainer.querySelector('img');
  248. this.closeButton = this.querySelector('.gallery__close');
  249. this.listeners();
  250. this.addGalleryImages();
  251. this.initalized = true;
  252. }
  253.  
  254. addGalleryImages () {
  255. this.thumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].map(thumb => {
  256. return thumb.cloneNode(true);
  257. });
  258.  
  259. this.thumbs.forEach(thumb => {
  260. this.imageContainer.appendChild(thumb);
  261. });
  262. }
  263.  
  264. updateGalleryImages () {
  265. if (!this.initalized) return;
  266.  
  267. const newThumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].filter(thumb => {
  268. return !this.thumbs.find(thisThumb.href === thumb.href);
  269. }).map(thumb => {
  270. return thumb.cloneNode(true);
  271. });
  272.  
  273. newThumbs.forEach(thumb => {
  274. this.thumbs.push(thumb);
  275. this.imageContainer.appendChild(thumb);
  276. });
  277. }
  278.  
  279. listeners () {
  280. this.addEventListener('click', event => {
  281. const clicked = event.target;
  282.  
  283. let imgLink = clicked.closest('.imgLink');
  284. if (imgLink?.dataset.filemime === 'video/webm') return;
  285.  
  286. if (imgLink) {
  287. event.preventDefault();
  288. this.mainImage.src = imgLink.href;
  289. }
  290.  
  291.  
  292. this.mainImageContainer.classList.toggle('active', !!imgLink);
  293.  
  294. if (clicked.closest('.gallery__close')) this.close();
  295. });
  296. }
  297.  
  298. open () {
  299. if (!this.initalized) this.init();
  300. this.classList.add('open');
  301. document.body.classList.add('fct-gallery-open');
  302. }
  303.  
  304. close () {
  305. this.classList.remove('open');
  306. document.body.classList.remove('fct-gallery-open');
  307. }
  308. }
  309.  
  310. window.customElements.define('fullchan-x-gallery', fullChanXGallery);
  311.  
  312.  
  313.  
  314. // Create fullchan-x gallery
  315. const fcxg = document.createElement('fullchan-x-gallery');
  316. fcxg.innerHTML = `
  317. <div class="gallery">
  318. <button id="#fcxg-close" class="gallery__close fullchan-x__option">Close</button>
  319. <div id="#fcxg-images" class="gallery__images"></div>
  320. <div id="#fcxg-main-image" class="gallery__main-image">
  321. <img src="" />
  322. </div>
  323. </div>
  324. `;
  325. document.body.appendChild(fcxg);
  326.  
  327.  
  328.  
  329. // Create fullchan-x elemnt
  330. const fcx = document.createElement('fullchan-x');
  331. fcx.innerHTML = `
  332. <div class="fcx__controls">
  333. <select id="thread-sort" class="fullchan-x__option">
  334. <option value="default">Default</option>
  335. <option value="replies">Replies</option>
  336. <option value="catbox">Catbox</option>
  337. </select>
  338.  
  339. <button id="fcx-gallery-btn" class="gallery__toggle fullchan-x__option">Gallery</button>
  340.  
  341. <div class="fcx__my-yous">
  342. <p class="my-yous__label fullchan-x__option">My (You)s</p>
  343. <div class="my-yous__yous" id="my-yous"></div>
  344. </div>
  345. </div>
  346. `;
  347. document.body.appendChild(fcx);
  348. fcx.init();
  349.  
  350.  
  351.  
  352. // Styles
  353. const style = document.createElement('style');
  354. style.innerHTML = `
  355. fullchan-x {
  356. display: block;
  357. position: fixed;
  358. top: 50px;
  359. right: 25px;
  360. padding: 10px;
  361. background: var(--contrast-color);
  362. border: 1px solid var(--navbar-text-color);
  363. color: var(--link-color);
  364. font-size: 14px;
  365. opacity: 0.5;
  366. }
  367.  
  368. fullchan-x:hover {
  369. opacity: 1;
  370. }
  371.  
  372. .divPosts {
  373. flex-direction: column;
  374. }
  375.  
  376. .fcx__controls {
  377. display: flex;
  378. flex-direction: column;
  379. gap: 6px;
  380. }
  381.  
  382. .my-yous__yous {
  383. display: none;
  384. flex-direction: column;
  385. }
  386.  
  387. .fullchan-x__option {
  388. padding: 6px 8px;
  389. background: white;
  390. border: none !important;
  391. border-radius: 0.2rem;
  392. transition: all ease 150ms;
  393. cursor: pointer;
  394. font-size: 12px;
  395. font-weight: 400;
  396. color: #374369;
  397. margin: 0;
  398. text-align: left;
  399. }
  400.  
  401. #thread-sort {
  402. padding-right: 0;
  403. }
  404.  
  405. .fcx__my-yous:hover .my-yous__yous {
  406. display: flex;
  407. padding-top: 10px;
  408. }
  409.  
  410. .innerPost:has(.quoteLink.you) {
  411. border-left: solid #dd003e 6px;
  412. }
  413.  
  414. .innerPost:has(.youName) {
  415. border-left: solid #68b723 6px;
  416. }
  417.  
  418. // --- Nested quotes ----
  419. // I don't know why it needs this, weird CSS inheritance on cloned element
  420. .nestedPost {}
  421. .divMessage .nestedPost {
  422. display: block;
  423. white-space: normal!important;
  424. overflow-wrap: anywhere;
  425. margin-top: 0.5em;
  426. border: 1px solid var(--navbar-text-color);
  427. }
  428.  
  429. .nestedPost .innerPost,
  430. .nestedPost .innerOP {
  431. width: 100%;
  432. }
  433.  
  434. .nestedPost .imgLink .imgExpanded {
  435. width: auto!important;
  436. height: auto!important;
  437. }
  438.  
  439. .my-yous__label.unseen {
  440. background: var(--link-hover-color);
  441. color: white;
  442. }
  443.  
  444. .my-yous__yous .unseen {
  445. font-weight: 900;
  446. color: var(--link-hover-color);
  447. }
  448.  
  449. // --- Gallery ---
  450. .fct-gallery-open,
  451. body.fct-gallery-open,
  452. body.fct-gallery-open #mainPanel {
  453. overflow: hidden!important;
  454. position: fixed!important; //fuck you, stop scolling cunt!
  455. }
  456.  
  457. body.fct-gallery-open fullchan-x {
  458. display: none;
  459. }
  460.  
  461. fullchan-x-gallery {
  462. position: fixed;
  463. top: 0;
  464. left: 0;
  465. width: 100%;
  466. background: rgba(0,0,0,0.9);
  467. display: none;
  468. height: 100%;
  469. overflow: auto;
  470. }
  471.  
  472. fullchan-x-gallery.open {
  473. display: block;
  474. }
  475.  
  476. fullchan-x-gallery .gallery {
  477. padding: 50px 10px 0
  478. }
  479.  
  480. fullchan-x-gallery .gallery__images {
  481. display: flex;
  482. width: 100%;
  483. height: 100%;
  484. justify-content: center;
  485. align-content: flex-start;
  486. gap: 4px 8px;
  487. flex-wrap: wrap;
  488. }
  489.  
  490. fullchan-x-gallery .imgLink img {
  491. border: solid white 1px;
  492. }
  493.  
  494. fullchan-x-gallery .imgLink[data-filemime="video/webm"] img {
  495. border: solid #68b723 4px;
  496. }
  497.  
  498. fullchan-x-gallery .gallery__close {
  499. position: fixed;
  500. top: 60px;
  501. right: 35px;
  502. padding: 6px 14px;
  503. z-index: 10;
  504. }
  505.  
  506. .gallery__main-image {
  507. display: none;
  508. position: fixed;
  509. top: 0;
  510. left: 0;
  511. width: 100%;
  512. height: 100%;
  513. justify-content: center;
  514. align-content: center;
  515. background: rgba(0,0,0,0.5);
  516. }
  517.  
  518. .gallery__main-image img {
  519. padding: 40px 10px 15px;
  520. height: auto;
  521. max-width: calc(100% - 20px);
  522. object-fit: contain;
  523. }
  524.  
  525. .gallery__main-image.active {
  526. display: flex;
  527. }
  528.  
  529. `;
  530.  
  531. document.head.appendChild(style);
  532.  
  533.  
  534. // Asuka and Eris (fantasy Asuka) are best girls