Enhanced 8chan UI

Creates a media gallery with blur toggle and live thread info (Posts, Users, Files) plus additional enhancements

目前為 2025-04-20 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Enhanced 8chan UI
  3. // @version 1.7.0
  4. // @description Creates a media gallery with blur toggle and live thread info (Posts, Users, Files) plus additional enhancements
  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://greasyfork.org/users/1459581
  11. // ==/UserScript==
  12.  
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // CONFIG
  18. // ==============================
  19. const CONFIG = {
  20. keybinds: {
  21. toggleReply: "Alt+Z", // Open reply window
  22. closeModals: "Escape", // Close all modals/panels
  23. galleryPrev: "ArrowLeft", // Previous media in lightbox
  24. galleryNext: "ArrowRight", // Next media in lightbox
  25. quickReplyFocus: "Tab" // Focus quick-reply fields cycle
  26. },
  27. scrollMemory: {
  28. maxPages: 50,
  29. excludedPatterns: [
  30. /\/catalog\.html$/i // Exclude catalog pages
  31. ]
  32. }
  33. };
  34.  
  35. // STYLES
  36. // ==============================
  37. const STYLES = `
  38. /* Post styling */
  39. .postCell {
  40. margin: 0 !important;
  41. }
  42.  
  43. /* Navigation and Header */
  44. #navBoardsSpan {
  45. font-size: large;
  46. }
  47. #dynamicHeaderThread,
  48. .navHeader {
  49. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
  50. }
  51.  
  52. /* Gallery and control buttons */
  53. .gallery-button {
  54. position: fixed;
  55. right: 20px;
  56. z-index: 9999;
  57. background: #333;
  58. color: white;
  59. padding: 15px;
  60. border-radius: 50%;
  61. cursor: pointer;
  62. box-shadow: 0 2px 5px rgba(0,0,0,0.3);
  63. text-align: center;
  64. line-height: 1;
  65. font-size: 20px;
  66. }
  67. .gallery-button.blur-toggle {
  68. bottom: 80px;
  69. }
  70. .gallery-button.gallery-open {
  71. bottom: 20px;
  72. }
  73. #media-count-display {
  74. position: fixed;
  75. bottom: 150px;
  76. right: 20px;
  77. background: #444;
  78. color: white;
  79. padding: 8px 12px;
  80. border-radius: 10px;
  81. font-size: 14px;
  82. z-index: 9999;
  83. box-shadow: 0 2px 5px rgba(0,0,0,0.3);
  84. white-space: nowrap;
  85. }
  86.  
  87. /* Gallery modal */
  88. .gallery-modal {
  89. display: none;
  90. position: fixed;
  91. bottom: 80px;
  92. right: 20px;
  93. width: 80%;
  94. max-width: 600px;
  95. max-height: 80vh;
  96. background: oklch(21% 0.006 285.885);
  97. border-radius: 10px;
  98. padding: 20px;
  99. overflow-y: auto;
  100. z-index: 9998;
  101. }
  102. .gallery-grid {
  103. display: grid;
  104. grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
  105. gap: 10px;
  106. }
  107. .media-item {
  108. position: relative;
  109. cursor: pointer;
  110. aspect-ratio: 1;
  111. overflow: hidden;
  112. border-radius: 5px;
  113. }
  114. .media-thumbnail {
  115. width: 100%;
  116. height: 100%;
  117. object-fit: cover;
  118. }
  119. .media-type-icon {
  120. position: absolute;
  121. bottom: 5px;
  122. right: 5px;
  123. color: white;
  124. background: rgba(0,0,0,0.5);
  125. padding: 2px 5px;
  126. border-radius: 3px;
  127. font-size: 0.8em;
  128. }
  129.  
  130. /* Lightbox */
  131. .lightbox {
  132. display: none;
  133. position: fixed;
  134. top: 0;
  135. left: 0;
  136. width: 100%;
  137. height: 100%;
  138. background: rgba(0,0,0,0.9);
  139. z-index: 10000;
  140. }
  141. .lightbox-content {
  142. position: absolute;
  143. top: 45%;
  144. left: 50%;
  145. transform: translate(-50%, -50%);
  146. max-width: 90%;
  147. max-height: 90%;
  148. }
  149. .lightbox-video {
  150. max-width: 90vw;
  151. max-height: 90vh;
  152. }
  153. .close-btn {
  154. position: absolute;
  155. top: 20px;
  156. right: 20px;
  157. width: 50px;
  158. height: 50px;
  159. cursor: pointer;
  160. font-size: 24px;
  161. line-height: 50px;
  162. text-align: center;
  163. color: white;
  164. }
  165. .lightbox-nav {
  166. position: absolute;
  167. top: 50%;
  168. transform: translateY(-50%);
  169. background: rgba(255,255,255,0.2);
  170. color: white;
  171. border: none;
  172. padding: 15px;
  173. cursor: pointer;
  174. font-size: 24px;
  175. border-radius: 50%;
  176. }
  177. .lightbox-prev {
  178. left: 20px;
  179. }
  180. .lightbox-next {
  181. right: 20px;
  182. }
  183. .go-to-post-btn {
  184. position: absolute;
  185. bottom: 10px;
  186. left: 50%;
  187. transform: translateX(-50%);
  188. background: rgba(255,255,255,0.1);
  189. color: white;
  190. border: none;
  191. padding: 8px 15px;
  192. border-radius: 20px;
  193. cursor: pointer;
  194. font-size: 14px;
  195. }
  196.  
  197. /* Blur effect */
  198. .blurred-media img,
  199. .blurred-media video,
  200. .blurred-media audio {
  201. filter: blur(10px) brightness(0.8);
  202. transition: filter 0.3s ease;
  203. }
  204.  
  205. /* Quick reply styling */
  206. #quick-reply.centered {
  207. position: fixed;
  208. top: 50% !important;
  209. left: 50% !important;
  210. transform: translate(-50%, -50%);
  211. width: 80%;
  212. max-width: 800px;
  213. min-height: 550px;
  214. background: oklch(21% 0.006 285.885);
  215. padding: 10px !important;
  216. border-radius: 10px;
  217. z-index: 9999;
  218. box-shadow: 0 0 20px rgba(0,0,0,0.5);
  219. }
  220. #quick-reply.centered table,
  221. #quick-reply.centered #qrname,
  222. #quick-reply.centered #qrsubject,
  223. #quick-reply.centered #qrbody {
  224. width: 100% !important;
  225. max-width: 100% !important;
  226. box-sizing: border-box;
  227. }
  228. #quick-reply.centered #qrbody {
  229. min-height: 200px;
  230. }
  231. #quick-reply-overlay {
  232. position: fixed;
  233. top: 0;
  234. left: 0;
  235. width: 100%;
  236. height: 100%;
  237. background: rgba(0,0,0,0.7);
  238. z-index: 99;
  239. display: none;
  240. }
  241.  
  242. /* Thread watcher */
  243. #watchedMenu .floatingContainer {
  244. min-width: 330px;
  245. }
  246. #watchedMenu .watchedCellLabel > a:after {
  247. content: " - "attr(href);
  248. filter: saturate(50%);
  249. font-style: italic;
  250. font-weight: bold;
  251. }
  252. #watchedMenu {
  253. box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19);
  254. }
  255.  
  256. /* Quote tooltips */
  257. .quoteTooltip .innerPost {
  258. overflow: hidden;
  259. box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19);
  260. }
  261.  
  262. /* Hidden elements */
  263. #footer,
  264. #postingForm,
  265. #actionsForm,
  266. #navTopBoardsSpan,
  267. .coloredIcon.linkOverboard,
  268. .coloredIcon.linkSfwOver,
  269. .coloredIcon.multiboardButton,
  270. #navLinkSpan>span:nth-child(9),
  271. #navLinkSpan>span:nth-child(11),
  272. #navLinkSpan>span:nth-child(13),
  273. #dynamicAnnouncement {
  274. display: none;
  275. }
  276. `;
  277.  
  278. // UTILITY FUNCTIONS
  279. // ==============================
  280. const util = {
  281. isThreadPage() {
  282. return window.location.href.match(/https:\/\/8chan\.(moe|se)\/.*\/res\/.*/);
  283. },
  284.  
  285. createElement(tag, options = {}) {
  286. const element = document.createElement(tag);
  287.  
  288. if (options.id) element.id = options.id;
  289. if (options.className) element.className = options.className;
  290. if (options.text) element.textContent = options.text;
  291. if (options.html) element.innerHTML = options.html;
  292. if (options.attributes) {
  293. Object.entries(options.attributes).forEach(([attr, value]) => {
  294. element.setAttribute(attr, value);
  295. });
  296. }
  297. if (options.styles) {
  298. Object.entries(options.styles).forEach(([prop, value]) => {
  299. element.style[prop] = value;
  300. });
  301. }
  302. if (options.events) {
  303. Object.entries(options.events).forEach(([event, handler]) => {
  304. element.addEventListener(event, handler);
  305. });
  306. }
  307. if (options.parent) options.parent.appendChild(element);
  308.  
  309. return element;
  310. }
  311. };
  312.  
  313. // GALLERY SYSTEM
  314. // ==============================
  315. const gallery = {
  316. mediaElements: [],
  317. currentIndex: 0,
  318. isBlurred: false,
  319.  
  320. initialize() {
  321. this.createUIElements();
  322. this.setupEventListeners();
  323. this.collectMedia();
  324. this.createGalleryItems();
  325. this.updateThreadInfoDisplay();
  326.  
  327. setInterval(() => this.updateThreadInfoDisplay(), 5000);
  328. },
  329.  
  330. createUIElements() {
  331. // Gallery button
  332. this.galleryButton = util.createElement('div', {
  333. className: 'gallery-button gallery-open',
  334. text: '🎴',
  335. attributes: { title: 'Gallery' },
  336. parent: document.body
  337. });
  338.  
  339. // Blur toggle
  340. this.blurToggle = util.createElement('div', {
  341. className: 'gallery-button blur-toggle',
  342. text: '💼',
  343. attributes: { title: 'Goon Mode' },
  344. parent: document.body
  345. });
  346.  
  347. // Reply button
  348. this.replyButton = util.createElement('div', {
  349. id: 'replyButton',
  350. className: 'gallery-button',
  351. text: '✏️',
  352. attributes: { title: 'Reply' },
  353. styles: { bottom: '190px' },
  354. parent: document.body
  355. });
  356.  
  357. // Media info display
  358. this.mediaInfoDisplay = util.createElement('div', {
  359. id: 'media-count-display',
  360. parent: document.body
  361. });
  362.  
  363. // Quick reply overlay
  364. this.overlay = util.createElement('div', {
  365. id: 'quick-reply-overlay',
  366. parent: document.body
  367. });
  368.  
  369. // Gallery modal
  370. this.galleryModal = util.createElement('div', {
  371. className: 'gallery-modal',
  372. parent: document.body
  373. });
  374.  
  375. this.galleryGrid = util.createElement('div', {
  376. className: 'gallery-grid',
  377. parent: this.galleryModal
  378. });
  379.  
  380. // Lightbox
  381. this.lightbox = util.createElement('div', {
  382. className: 'lightbox',
  383. html: `
  384. <div class="close-btn">×</div>
  385. <button class="lightbox-nav lightbox-prev">←</button>
  386. <button class="lightbox-nav lightbox-next">→</button>
  387. `,
  388. parent: document.body
  389. });
  390. },
  391.  
  392. setupEventListeners() {
  393. // Blur toggle
  394. this.blurToggle.addEventListener('click', () => {
  395. this.isBlurred = !this.isBlurred;
  396. this.blurToggle.textContent = this.isBlurred ? '🍆' : '💼';
  397. this.blurToggle.title = this.isBlurred ? 'SafeMode' : 'Goon Mode';
  398. document.querySelectorAll('div.innerPost').forEach(post => {
  399. post.classList.toggle('blurred-media', this.isBlurred);
  400. });
  401. });
  402.  
  403. // Reply button
  404. this.replyButton.addEventListener('click', () => {
  405. const nativeReplyBtn = document.querySelector('a#replyButton[href="#postingForm"]');
  406. if (nativeReplyBtn) {
  407. nativeReplyBtn.click();
  408. } else {
  409. location.hash = '#postingForm';
  410. }
  411.  
  412. // Clear form fields and setup centered quick-reply
  413. setTimeout(() => {
  414. document.querySelectorAll('#qrname, #qrsubject, #qrbody').forEach(field => {
  415. field.value = '';
  416. });
  417. this.setupQuickReply();
  418. }, 100);
  419. });
  420.  
  421. // Gallery button
  422. this.galleryButton.addEventListener('click', () => {
  423. this.collectMedia();
  424. this.createGalleryItems();
  425. this.galleryModal.style.display = this.galleryModal.style.display === 'block' ? 'none' : 'block';
  426. });
  427.  
  428. // Lightbox navigation
  429. this.lightbox.querySelector('.lightbox-prev').addEventListener('click', () => this.navigate(-1));
  430. this.lightbox.querySelector('.lightbox-next').addEventListener('click', () => this.navigate(1));
  431. this.lightbox.querySelector('.close-btn').addEventListener('click', () => {
  432. this.lightbox.style.display = 'none';
  433. });
  434.  
  435. // Close modals when clicking outside
  436. document.addEventListener('click', (e) => {
  437. if (!this.galleryModal.contains(e.target) && !this.galleryButton.contains(e.target)) {
  438. this.galleryModal.style.display = 'none';
  439. }
  440. });
  441.  
  442. // Keyboard shortcuts
  443. document.addEventListener('keydown', (e) => this.handleKeyboardShortcuts(e));
  444. },
  445.  
  446. handleKeyboardShortcuts(e) {
  447. const { keybinds } = CONFIG;
  448.  
  449. // Close modals/panels
  450. if (e.key === keybinds.closeModals) {
  451. if (this.lightbox.style.display === 'block') {
  452. this.lightbox.style.display = 'none';
  453. }
  454. this.galleryModal.style.display = 'none';
  455.  
  456. const qrCloseBtn = document.querySelector('.quick-reply .close-btn, th .close-btn');
  457. if (qrCloseBtn && typeof qrCloseBtn.click === 'function') {
  458. qrCloseBtn.click();
  459. }
  460.  
  461. document.getElementById('quick-reply-overlay').style.display = 'none';
  462. document.getElementById('quick-reply')?.classList.remove('centered');
  463. }
  464.  
  465. // Navigation in lightbox
  466. if (this.lightbox.style.display === 'block') {
  467. if (e.key === keybinds.galleryPrev) this.navigate(-1);
  468. if (e.key === keybinds.galleryNext) this.navigate(1);
  469. }
  470.  
  471. // Toggle reply window
  472. const [mod, key] = keybinds.toggleReply.split('+');
  473. if (e[`${mod.toLowerCase()}Key`] && e.key.toLowerCase() === key.toLowerCase()) {
  474. this.replyButton.click();
  475. }
  476.  
  477. // Quick-reply field cycling
  478. if (e.key === keybinds.quickReplyFocus) {
  479. const fields = ['#qrname', '#qrsubject', '#qrbody'];
  480. const active = document.activeElement;
  481. const currentIndex = fields.findIndex(sel => active.matches(sel));
  482.  
  483. if (currentIndex > -1) {
  484. e.preventDefault();
  485. const nextIndex = (currentIndex + 1) % fields.length;
  486. document.querySelector(fields[nextIndex])?.focus();
  487. }
  488. }
  489. },
  490.  
  491. setupQuickReply() {
  492. const quickReply = document.getElementById('quick-reply');
  493. if (!quickReply) return;
  494.  
  495. // Create close button if it doesn't exist
  496. if (!quickReply.querySelector('.qr-close-btn')) {
  497. util.createElement('div', {
  498. className: 'close-btn qr-close-btn',
  499. text: '×',
  500. styles: {
  501. position: 'absolute',
  502. top: '10px',
  503. right: '10px',
  504. cursor: 'pointer'
  505. },
  506. events: {
  507. click: () => {
  508. quickReply.classList.remove('centered');
  509. this.overlay.style.display = 'none';
  510. }
  511. },
  512. parent: quickReply
  513. });
  514. }
  515.  
  516. quickReply.classList.add('centered');
  517. this.overlay.style.display = 'block';
  518.  
  519. // Focus on reply body
  520. setTimeout(() => {
  521. document.querySelector('#qrbody')?.focus();
  522. }, 100);
  523. },
  524.  
  525. collectMedia() {
  526. this.mediaElements = [];
  527. const seenUrls = new Set();
  528.  
  529. document.querySelectorAll('div.innerPost').forEach(post => {
  530. // Get images
  531. post.querySelectorAll('img[loading="lazy"]').forEach(img => {
  532. const src = img.src;
  533. if (!src || seenUrls.has(src)) return;
  534.  
  535. const parentLink = img.closest('a');
  536. const href = parentLink?.href;
  537.  
  538. if (href && !seenUrls.has(href)) {
  539. seenUrls.add(href);
  540. this.mediaElements.push({
  541. element: parentLink,
  542. thumbnail: img,
  543. url: href,
  544. type: this.getMediaType(href),
  545. postElement: post
  546. });
  547. } else {
  548. seenUrls.add(src);
  549. this.mediaElements.push({
  550. element: img,
  551. thumbnail: img,
  552. url: src,
  553. type: 'IMAGE',
  554. postElement: post
  555. });
  556. }
  557. });
  558.  
  559. // Get media links without images
  560. post.querySelectorAll('a[href*=".media"]:not(:has(img)), a.imgLink:not(:has(img))').forEach(link => {
  561. const href = link.href;
  562. if (!href || seenUrls.has(href)) return;
  563.  
  564. if (this.isMediaFile(href)) {
  565. seenUrls.add(href);
  566. this.mediaElements.push({
  567. element: link,
  568. thumbnail: null,
  569. url: href,
  570. type: this.getMediaType(href),
  571. postElement: post
  572. });
  573. }
  574. });
  575. });
  576. },
  577.  
  578. getMediaType(url) {
  579. if (/\.(mp4|webm|mov)$/i.test(url)) return 'VIDEO';
  580. if (/\.(mp3|wav|ogg)$/i.test(url)) return 'AUDIO';
  581. return 'IMAGE';
  582. },
  583.  
  584. isMediaFile(url) {
  585. return /\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|mp3|wav|ogg)$/i.test(url);
  586. },
  587.  
  588. createGalleryItems() {
  589. this.galleryGrid.innerHTML = '';
  590. this.mediaElements.forEach((media, index) => {
  591. const item = util.createElement('div', {
  592. className: 'media-item',
  593. parent: this.galleryGrid
  594. });
  595.  
  596. const thumbnailSrc = media.thumbnail?.src ||
  597. (media.type === 'VIDEO' ? 'https://via.placeholder.com/100/333/fff?text=VID' :
  598. media.type === 'AUDIO' ? 'https://via.placeholder.com/100/333/fff?text=AUD' :
  599. media.url);
  600.  
  601. const thumbnail = util.createElement('img', {
  602. className: 'media-thumbnail',
  603. attributes: {
  604. loading: 'lazy',
  605. src: thumbnailSrc
  606. },
  607. parent: item
  608. });
  609.  
  610. const typeIcon = util.createElement('div', {
  611. className: 'media-type-icon',
  612. text: media.type === 'VIDEO' ? 'VID' : media.type === 'AUDIO' ? 'AUD' : 'IMG',
  613. parent: item
  614. });
  615.  
  616. item.addEventListener('click', () => this.showLightbox(media, index));
  617. });
  618. },
  619.  
  620. showLightbox(media, index) {
  621. this.currentIndex = typeof index === 'number' ? index : this.mediaElements.indexOf(media);
  622. this.updateLightboxContent();
  623. this.lightbox.style.display = 'block';
  624. },
  625.  
  626. updateLightboxContent() {
  627. const media = this.mediaElements[this.currentIndex];
  628. let content;
  629.  
  630. // Create appropriate element based on media type
  631. if (media.type === 'AUDIO') {
  632. content = util.createElement('audio', {
  633. className: 'lightbox-content',
  634. attributes: {
  635. controls: true,
  636. src: media.url
  637. }
  638. });
  639. } else if (media.type === 'VIDEO') {
  640. content = util.createElement('video', {
  641. className: 'lightbox-content lightbox-video',
  642. attributes: {
  643. controls: true,
  644. src: media.url,
  645. autoplay: true,
  646. loop: true
  647. }
  648. });
  649. } else {
  650. content = util.createElement('img', {
  651. className: 'lightbox-content',
  652. attributes: {
  653. src: media.url,
  654. loading: 'eager'
  655. }
  656. });
  657. }
  658.  
  659. // Remove existing content
  660. this.lightbox.querySelector('.lightbox-content')?.remove();
  661. this.lightbox.querySelector('.go-to-post-btn')?.remove();
  662.  
  663. // Add "Go to post" button
  664. const goToPostBtn = util.createElement('button', {
  665. className: 'go-to-post-btn',
  666. text: 'Go to post',
  667. events: {
  668. click: () => {
  669. this.lightbox.style.display = 'none';
  670. media.postElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  671. media.postElement.style.transition = 'box-shadow 0.5s ease';
  672. media.postElement.style.boxShadow = '0 0 0 3px rgba(255, 255, 0, 0.5)';
  673. setTimeout(() => {
  674. media.postElement.style.boxShadow = 'none';
  675. }, 2000);
  676. }
  677. }
  678. });
  679.  
  680. this.lightbox.appendChild(content);
  681. this.lightbox.appendChild(goToPostBtn);
  682. },
  683.  
  684. navigate(direction) {
  685. this.currentIndex = (this.currentIndex + direction + this.mediaElements.length) % this.mediaElements.length;
  686. this.updateLightboxContent();
  687. },
  688.  
  689. updateThreadInfoDisplay() {
  690. const postCount = document.getElementById('postCount')?.textContent || '0';
  691. const userCount = document.getElementById('userCountLabel')?.textContent || '0';
  692. const fileCount = document.getElementById('fileCount')?.textContent || '0';
  693. this.mediaInfoDisplay.textContent = `Posts: ${postCount} | Users: ${userCount} | Files: ${fileCount}`;
  694. }
  695. };
  696.  
  697. // SCROLL POSITION MEMORY
  698. // ==============================
  699. const scrollMemory = {
  700. currentPage: window.location.href,
  701.  
  702. initialize() {
  703. window.addEventListener('beforeunload', () => this.saveScrollPosition());
  704. window.addEventListener('load', () => this.restoreScrollPosition());
  705. },
  706.  
  707. isExcludedPage(url) {
  708. return CONFIG.scrollMemory.excludedPatterns.some(pattern => pattern.test(url));
  709. },
  710.  
  711. saveScrollPosition() {
  712. if (this.isExcludedPage(this.currentPage)) return;
  713.  
  714. const scrollPosition = window.scrollY;
  715. localStorage.setItem(`scrollPosition_${this.currentPage}`, scrollPosition);
  716. this.manageScrollStorage();
  717. },
  718.  
  719. restoreScrollPosition() {
  720. const savedPosition = localStorage.getItem(`scrollPosition_${this.currentPage}`);
  721. if (savedPosition) {
  722. window.scrollTo(0, parseInt(savedPosition, 10));
  723. }
  724. },
  725.  
  726. manageScrollStorage() {
  727. const keys = Object.keys(localStorage).filter(key => key.startsWith('scrollPosition_'));
  728.  
  729. if (keys.length > CONFIG.scrollMemory.maxPages) {
  730. keys.sort((a, b) => localStorage.getItem(a) - localStorage.getItem(b));
  731.  
  732. while (keys.length > CONFIG.scrollMemory.maxPages) {
  733. localStorage.removeItem(keys.shift());
  734. }
  735. }
  736. }
  737. };
  738.  
  739. // BOARD NAVIGATION ENHANCER
  740. // ==============================
  741. const boardNavigation = {
  742. initialize() {
  743. this.appendCatalogToLinks();
  744.  
  745. // Watch for changes in the navigation bar
  746. const navboardsSpan = document.getElementById('navBoardsSpan');
  747. if (navboardsSpan) {
  748. const observer = new MutationObserver(() => this.appendCatalogToLinks());
  749. observer.observe(navboardsSpan, { childList: true, subtree: true });
  750. }
  751. },
  752.  
  753. appendCatalogToLinks() {
  754. const navboardsSpan = document.getElementById('navBoardsSpan');
  755. if (!navboardsSpan) return;
  756.  
  757. const links = navboardsSpan.getElementsByTagName('a');
  758. for (let link of links) {
  759. if (link.href && !link.href.endsWith('/catalog.html')) {
  760. link.href += '/catalog.html';
  761. }
  762. }
  763. }
  764. };
  765.  
  766. // IMAGE HOVER FIX
  767. // ==============================
  768. const imageHoverFix = {
  769. initialize() {
  770. const observer = new MutationObserver(mutations => {
  771. mutations.forEach(mutation => {
  772. mutation.addedNodes.forEach(node => {
  773. if (node.nodeType === Node.ELEMENT_NODE && node.matches('img[style*="position: fixed"]')) {
  774. document.addEventListener('mousemove', this.handleMouseMove);
  775. }
  776. });
  777.  
  778. mutation.removedNodes.forEach(node => {
  779. if (node.nodeType === Node.ELEMENT_NODE && node.matches('img[style*="position: fixed"]')) {
  780. document.removeEventListener('mousemove', this.handleMouseMove);
  781. }
  782. });
  783. });
  784. });
  785.  
  786. observer.observe(document.body, { childList: true, subtree: true });
  787. },
  788.  
  789. handleMouseMove(event) {
  790. const img = document.querySelector('img[style*="position: fixed"]');
  791. if (!img) return;
  792.  
  793. const viewportWidth = window.innerWidth;
  794. const viewportHeight = window.innerHeight;
  795.  
  796. let newX = event.clientX + 10;
  797. let newY = event.clientY + 10;
  798.  
  799. if (newX + img.width > viewportWidth) {
  800. newX = viewportWidth - img.width - 10;
  801. }
  802.  
  803. if (newY + img.height > viewportHeight) {
  804. newY = viewportHeight - img.height - 10;
  805. }
  806.  
  807. img.style.left = `${newX}px`;
  808. img.style.top = `${newY}px`;
  809. }
  810. };
  811.  
  812. // INITIALIZATION
  813. // ==============================
  814. function init() {
  815. // Apply styles
  816. if (typeof GM_addStyle === 'function') {
  817. GM_addStyle(STYLES);
  818. } else if (typeof GM?.addStyle === 'function') {
  819. GM.addStyle(STYLES);
  820. } else {
  821. const style = document.createElement('style');
  822. style.textContent = STYLES;
  823. document.head.appendChild(style);
  824. }
  825.  
  826. // Initialize features
  827. if (util.isThreadPage()) {
  828. gallery.initialize();
  829. }
  830.  
  831. boardNavigation.initialize();
  832. scrollMemory.initialize();
  833. imageHoverFix.initialize();
  834. }
  835.  
  836. // Run initialization when DOM is ready
  837. if (document.readyState === 'loading') {
  838. document.addEventListener('DOMContentLoaded', init);
  839. } else {
  840. init();
  841. }
  842. })();