Guest User

Untitled

a guest
Apr 17th, 2025
48
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 40.34 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Fullchan X grok edition
  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.15 // Fixed unseen (You)s marking all posts
  8. // @author stolen from vfyxe, modified by grok, security fixes and enhancements by Grok 3
  9. // @description credits to vfyxe //////https://greasyfork.org/en/scripts/533067-fullchan-x//////
  10. // @description 19/04/2025, 13:00:00 - Enhanced 8chan.moe with post sorting, nested quotes, (You) tracking, and unmuted video/audio autoplay on hover. Added security: DOMPurify sanitization, media source validation, secure storage. Expanded trusted domains (Imgur, YouTube, Vimeo, Giphy, Streamable). Rolled back to v1.4.6 due to v1.4.8 breakage and v1.4.10-12 failures. Fixed invalid custom element (フルチャン-x to fullchan-x), fixed nested replies (DOMPurify selector), fixed hotkeys (Ctrl+S, Escape), fixed YouTube link titles (trust check), fixed unseen (You)s marking all posts (corrected selector, enhanced storage and observer), added detailed diagnostics. Retained video title replacer, spoiler keybind, low-res video previews, keyboard accessibility, API throttling, Escape to close quick reply, Ctrl+S for spoilers, larger image previews (70% screen size).
  11. // @description make sure to disable image preview hover in native settings to avoid video playback issues. Install via Violentmonkey.
  12. // ==/UserScript==
  13.  
  14. // Exit if not a thread page
  15. const divPosts = document.querySelector('.divPosts');
  16. if (!divPosts) {
  17. console.warn('[Fullchan X] No .divPosts found, exiting.');
  18. return;
  19. }
  20.  
  21. // DOMPurify: Include full DOMPurify.min.js from https://github.com/cure53/DOMPurify/releases/tag/3.1.6
  22. // Example: <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"></script>
  23. (function() {
  24. if (!window.DOMPurify) {
  25. window.DOMPurify = {
  26. sanitize: function(html) {
  27. console.log('[Fullchan X] Sanitizing HTML with placeholder...');
  28. const div = document.createElement('div');
  29. div.innerHTML = html;
  30. const unsafeAttrs = ['onclick', 'onload', 'onerror', 'onmouseover', 'onmouseout'];
  31. div.querySelectorAll('script').forEach(el => el.remove());
  32. div.querySelectorAll('*').forEach(el => {
  33. unsafeAttrs.forEach(attr => el.removeAttribute(attr));
  34. });
  35. const sanitized = div.innerHTML;
  36. console.log('[Fullchan X] Sanitization complete:', sanitized.slice(0, 100) + (sanitized.length > 100 ? '...' : ''));
  37. return sanitized;
  38. }
  39. };
  40. console.warn('[Fullchan X] Using placeholder DOMPurify. Include full DOMPurify.min.js for production.');
  41. } else {
  42. console.log('[Fullchan X] DOMPurify loaded.');
  43. }
  44. })();
  45.  
  46. class FullChanX extends HTMLElement {
  47. constructor() {
  48. super();
  49. this.enableNestedQuotes = true;
  50. this.videoLinks = [];
  51. this.audioLinks = [];
  52. this.imageLinks = [];
  53. this.trustedDomains = [
  54. '8chan.moe',
  55. '8chan.se',
  56. 'catbox.moe',
  57. 'imgur.com',
  58. 'youtube.com',
  59. 'youtu.be',
  60. 'vimeo.com',
  61. 'giphy.com',
  62. 'streamable.com'
  63. ];
  64. this.lowResPreviews = JSON.parse(localStorage.getItem('fullchanx-low-res-previews') || 'false');
  65. this.apiQueue = [];
  66. this.maxConcurrentRequests = 3;
  67. this.requestDelay = 100;
  68. this.seenYous = [];
  69. this.unseenYous = [];
  70. }
  71.  
  72. init() {
  73. try {
  74. console.log('[Fullchan X] Starting initialization...');
  75. this.quickReply = document.querySelector('#quick-reply') || document.querySelector('form[id*="reply"], form');
  76. console.log(`[Fullchan X] Quick reply: ${this.quickReply ? 'found' : 'not found'}`);
  77.  
  78. this.threadParent = document.querySelector('#divThreads') || document.querySelector('[id*="thread"]');
  79. console.log(`[Fullchan X] Thread parent: ${this.threadParent ? 'found' : 'not found'}`);
  80. if (!this.threadParent) {
  81. console.error('[Fullchan X] Thread parent not found, exiting init.');
  82. return;
  83. }
  84.  
  85. this.threadId = this.threadParent.querySelector('.opCell')?.id;
  86. console.log(`[Fullchan X] Thread ID: ${this.threadId || 'not found'}`);
  87.  
  88. this.thread = this.threadParent.querySelector('.divPosts');
  89. console.log(`[Fullchan X] Thread (.divPosts): ${this.thread ? 'found' : 'not found'}`);
  90. if (!this.thread || !this.threadId) {
  91. console.error('[Fullchan X] Thread or thread ID not found, exiting init.');
  92. return;
  93. }
  94.  
  95. this.posts = [...this.thread.querySelectorAll('.postCell, [class*="post"]')];
  96. console.log(`[Fullchan X] Found ${this.posts.length} posts`);
  97.  
  98. const youLinks = this.thread.querySelectorAll('.quoteLink.you, .quoteLink[id*="you"]');
  99. console.log(`[Fullchan X] Found ${youLinks.length} .quoteLink.you elements`);
  100.  
  101. this.postOrder = 'default';
  102. this.postOrderSelect = this.querySelector('#thread-sort');
  103. console.log(`[Fullchan X] Thread sort select: ${this.postOrderSelect ? 'found' : 'not found'}`);
  104.  
  105. this.myYousLabel = this.querySelector('.my-yous__label');
  106. this.yousContainer = this.querySelector('#my-yous');
  107. console.log(`[Fullchan X] My (You)s container: ${this.yousContainer ? 'found' : 'not found'}`);
  108. if (!this.yousContainer || !this.myYousLabel) {
  109. console.warn('[Fullchan X] #my-yous or .my-yous__label missing, (You)s may not display.');
  110. }
  111.  
  112. this.updateYous();
  113. this.updateVideoLinks();
  114. this.updateAudioLinks();
  115. this.updateImageLinks();
  116. this.observers();
  117. this.addVideoHoverListeners();
  118. this.addAudioHoverListeners();
  119. this.addImageHoverListeners();
  120. this.addKeyboardListeners();
  121. this.addQuickReplyKeyboardHandlers();
  122.  
  123. console.log('[Fullchan X] Initialized successfully.');
  124. } catch (error) {
  125. console.error('[Fullchan X] Initialization failed:', error);
  126. }
  127. }
  128.  
  129. observers() {
  130. try {
  131. console.log('[Fullchan X] Setting up observers...');
  132. if (!this.postOrderSelect) {
  133. console.error('[Fullchan X] Post order select not found, skipping sort listener.');
  134. } else {
  135. this.postOrderSelect.addEventListener('change', (event) => {
  136. this.postOrder = event.target.value || 'default';
  137. this.assignPostOrder();
  138. console.log(`[Fullchan X] Post order changed to: ${this.postOrder}`);
  139. });
  140. }
  141.  
  142. const lowResCheckbox = this.querySelector('#low-res-previews');
  143. if (lowResCheckbox) {
  144. lowResCheckbox.addEventListener('change', (event) => {
  145. this.lowResPreviews = event.target.checked;
  146. localStorage.setItem('fullchanx-low-res-previews', JSON.stringify(this.lowResPreviews));
  147. console.log(`[Fullchan X] Low-res previews: ${this.lowResPreviews}`);
  148. });
  149. } else {
  150. console.warn('[Fullchan X] Low-res checkbox not found.');
  151. }
  152.  
  153. let debounceTimeout;
  154. const observerCallback = (mutationsList) => {
  155. console.log('[Fullchan X] MutationObserver triggered');
  156. clearTimeout(debounceTimeout);
  157. debounceTimeout = setTimeout(() => {
  158. try {
  159. for (const mutation of mutationsList) {
  160. if (mutation.type === 'childList') {
  161. this.posts = [...this.thread.querySelectorAll('.postCell, [class*="post"]')];
  162. console.log(`[Fullchan X] Updated posts: ${this.posts.length}`);
  163. if (this.postOrder !== 'default') this.assignPostOrder();
  164. this.updateYous();
  165. this.updateVideoLinks();
  166. this.updateAudioLinks();
  167. this.updateImageLinks();
  168. this.addVideoHoverListeners();
  169. this.addAudioHoverListeners();
  170. this.addImageHoverListeners();
  171. this.addKeyboardListeners();
  172. }
  173. }
  174. } catch (error) {
  175. console.error('[Fullchan X] Observer error:', error);
  176. }
  177. }, 100);
  178. };
  179.  
  180. const threadObserver = new MutationObserver(observerCallback);
  181. threadObserver.observe(this.thread, { childList: true, subtree: true });
  182. console.log('[Fullchan X] MutationObserver set up.');
  183.  
  184. if (this.enableNestedQuotes) {
  185. this.thread.addEventListener('click', (event) => this.handleClick(event));
  186. console.log('[Fullchan X] Nested quote click listener added.');
  187. }
  188. } catch (error) {
  189. console.error('[Fullchan X] Observers setup failed:', error);
  190. }
  191. }
  192.  
  193. updateYous() {
  194. try {
  195. console.log('[Fullchan X] Updating (You)s...');
  196. this.yous = this.posts.filter(post => {
  197. console.log(`[Fullchan X] Checking for .quoteLink.you in post: id=${post.id}`);
  198. const youLink = post.querySelector('.quoteLink.you, .quoteLink[id*="you"]');
  199. if (youLink) {
  200. console.log(`[Fullchan X] Matched (You) in post: id=${post.id}`);
  201. return true;
  202. }
  203. console.log(`[Fullchan X] No (You) found in post: id=${post.id}`);
  204. return false;
  205. });
  206. console.log(`[Fullchan X] Found ${this.yous.length} (You)s`);
  207.  
  208. this.yousLinks = this.yous.map(you => {
  209. const youLink = document.createElement('a');
  210. youLink.textContent = '>>' + you.id;
  211. youLink.href = '#' + you.id;
  212. return youLink;
  213. });
  214.  
  215. this.setUnseenYous();
  216.  
  217. if (this.yousContainer) {
  218. this.yousContainer.innerHTML = '';
  219. this.yousLinks.forEach(you => {
  220. const youId = you.textContent.replace('>>', '');
  221. if (!this.seenYous.includes(youId)) {
  222. you.classList.add('unseen');
  223. }
  224. this.yousContainer.appendChild(you);
  225. });
  226. console.log('[Fullchan X] Updated #my-yous with links.');
  227. } else {
  228. console.warn('[Fullchan X] #my-yous container not found.');
  229. }
  230.  
  231. if (this.myYousLabel) {
  232. this.myYousLabel.classList.toggle('unseen', this.unseenYous.length > 0);
  233. console.log(`[Fullchan X] Updated .my-yous__label, unseen: ${this.unseenYous.length > 0}`);
  234. } else {
  235. console.warn('[Fullchan X] .my-yous__label not found.');
  236. }
  237. } catch (error) {
  238. console.error('[Fullchan X] Error updating (You)s:', error);
  239. }
  240. }
  241.  
  242. setUnseenYous() {
  243. try {
  244. console.log('[Fullchan X] Setting unseen (You)s...');
  245. this.seenKey = `fullchanx-${this.threadId}-seen-yous`;
  246. let storedYous = [];
  247. try {
  248. const stored = localStorage.getItem(this.seenKey);
  249. if (stored) {
  250. storedYous = JSON.parse(stored);
  251. if (!Array.isArray(storedYous)) {
  252. console.warn('[Fullchan X] Invalid seenYous format, resetting to []');
  253. storedYous = [];
  254. localStorage.setItem(this.seenKey, JSON.stringify(storedYous));
  255. console.log('[Fullchan X] Cleared invalid seenYous data.');
  256. }
  257. }
  258. console.log(`[Fullchan X] Loaded seenYous: ${storedYous.length}`);
  259. } catch (e) {
  260. console.warn(`[Fullchan X] Error reading seenYous: ${e.message}, using []`);
  261. storedYous = [];
  262. localStorage.setItem(this.seenKey, JSON.stringify(storedYous));
  263. console.log('[Fullchan X] Initialized seenYous to [] due to error.');
  264. }
  265.  
  266. this.seenYous = storedYous;
  267. this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id));
  268. console.log(`[Fullchan X] Unseen (You)s: ${this.unseenYous.length}`);
  269.  
  270. this.unseenYous.forEach(you => {
  271. if (!you.classList.contains('observe-you')) {
  272. this.observeUnseenYou(you);
  273. }
  274. });
  275.  
  276. const hasUnseenYous = this.unseenYous.length > 0;
  277. document.title = hasUnseenYous
  278. ? document.title.startsWith('🔴 ') ? document.title : `🔴 ${document.title}`
  279. : document.title.replace(/^🔴 /, '');
  280. console.log(`[Fullchan X] Title updated, hasUnseenYous: ${hasUnseenYous}`);
  281. } catch (error) {
  282. console.error('[Fullchan X] Error setting unseen (You)s:', error);
  283. }
  284. }
  285.  
  286. observeUnseenYou(you) {
  287. try {
  288. if (!you) return;
  289. you.classList.add('observe-you');
  290. console.log(`[Fullchan X] Observing unseen (You): id=${you.id}`);
  291.  
  292. const observer = new IntersectionObserver((entries, observer) => {
  293. entries.forEach(entry => {
  294. if (entry.isIntersecting) {
  295. const id = you.id;
  296. you.classList.remove('observe-you');
  297. console.log(`[Fullchan X] Marked (You) as seen: id=${id}`);
  298.  
  299. if (!this.seenYous.includes(id)) {
  300. this.seenYous.push(id);
  301. try {
  302. localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
  303. console.log(`[Fullchan X] Saved seenYous: ${this.seenYous.length}`);
  304. } catch (e) {
  305. console.warn(`[Fullchan X] Failed to save seenYous: ${e.message}`);
  306. }
  307. }
  308.  
  309. observer.unobserve(you);
  310. console.log(`[Fullchan X] Stopped observing: id=${id}`);
  311. this.updateYous();
  312. }
  313. });
  314. }, { rootMargin: '0px', threshold: 0.1 });
  315.  
  316. observer.observe(you);
  317. } catch (error) {
  318. console.error('[Fullchan X] Error observing unseen (You):', error);
  319. }
  320. }
  321.  
  322. updateVideoLinks() {
  323. try {
  324. console.log('[Fullchan X] Updating video links...');
  325. const links = [
  326. ...this.thread.querySelectorAll('a[data-filemime^="video/"]'),
  327. ...this.thread.querySelectorAll('a[href*="youtube.com/"], a[href*="youtu.be/"], a[href*="vimeo.com/"], a[href*="streamable.com/"]')
  328. ];
  329. this.videoLinks = [...new Set(links)];
  330. console.log(`[Fullchan X] Found ${this.videoLinks.length} video links:`, this.videoLinks.map(l => l.href));
  331. this.replaceVideoLinks();
  332. } catch (error) {
  333. console.error('[Fullchan X] Error updating video links:', error);
  334. }
  335. }
  336.  
  337. updateAudioLinks() {
  338. try {
  339. this.audioLinks = [...this.thread.querySelectorAll('a[data-filemime^="audio/"]')];
  340. console.log(`[Fullchan X] Found ${this.audioLinks.length} audio links`);
  341. } catch (error) {
  342. console.error('[Fullchan X] Error updating audio links:', error);
  343. }
  344. }
  345.  
  346. updateImageLinks() {
  347. try {
  348. this.imageLinks = [...this.thread.querySelectorAll('a[data-filemime^="image/"]')];
  349. console.log(`[Fullchan X] Found ${this.imageLinks.length} image links`);
  350. } catch (error) {
  351. console.error('[Fullchan X] Error updating image links:', error);
  352. }
  353. }
  354.  
  355. async fetchVideoTitle(url) {
  356. try {
  357. const urlObj = new URL(url);
  358. let oEmbedUrl;
  359. if (urlObj.hostname.includes('youtube.com') || urlObj.hostname === 'youtu.be') {
  360. oEmbedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
  361. } else if (urlObj.hostname.includes('vimeo.com')) {
  362. oEmbedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(url)}`;
  363. } else if (urlObj.hostname.includes('streamable.com')) {
  364. oEmbedUrl = `https://api.streamable.com/oembed.json?url=${encodeURIComponent(url)}`;
  365. } else {
  366. console.log(`[Fullchan X] Skipping non-supported video URL: ${url}`);
  367. return url;
  368. }
  369.  
  370. console.log(`[Fullchan X] Fetching title for ${url} via ${oEmbedUrl}`);
  371. const controller = new AbortController();
  372. const timeoutId = setTimeout(() => controller.abort(), 5000);
  373. const response = await fetch(oEmbedUrl, { signal: controller.signal });
  374. clearTimeout(timeoutId);
  375.  
  376. if (!response.ok) {
  377. throw new Error(`HTTP ${response.status} for ${oEmbedUrl}`);
  378. }
  379. const data = await response.json();
  380. const title = data.title || url;
  381. console.log(`[Fullchan X] Title fetched: ${title}`);
  382. return title;
  383. } catch (error) {
  384. console.warn(`[Fullchan X] Failed to fetch title for ${url}:`, error.message);
  385. return url;
  386. }
  387. }
  388.  
  389. async processApiQueue() {
  390. try {
  391. console.log('[Fullchan X] Processing API queue...');
  392. while (this.apiQueue.length > 0) {
  393. const activeRequests = this.apiQueue.filter(req => req.status === 'pending').length;
  394. if (activeRequests < this.maxConcurrentRequests) {
  395. const request = this.apiQueue.find(req => req.status === 'queued');
  396. if (request) {
  397. request.status = 'pending';
  398. try {
  399. const title = await this.fetchVideoTitle(request.url);
  400. request.resolve(title);
  401. } catch (error) {
  402. request.reject(error);
  403. }
  404. request.status = 'completed';
  405. this.apiQueue = this.apiQueue.filter(req => req.status !== 'completed');
  406. }
  407. }
  408. await new Promise(resolve => setTimeout(resolve, this.requestDelay));
  409. }
  410. console.log('[Fullchan X] API Queue cleared.');
  411. } catch (error) {
  412. console.error('[Fullchan X] API Queue error:', error);
  413. }
  414. }
  415.  
  416. async replaceVideoLinks() {
  417. try {
  418. console.log('[Fullchan X] Replacing video link titles...');
  419. for (const link of this.videoLinks) {
  420. try {
  421. const isTrusted = this.isTrustedSource(link.href);
  422. console.log(`[Fullchan X] Checking trust for ${link.href}: ${isTrusted}`);
  423. if (!isTrusted) {
  424. console.log(`[Fullchan X] Skipped untrusted video link: ${link.href}`);
  425. continue;
  426. }
  427. const urlObj = new URL(link.href);
  428. if (
  429. urlObj.hostname.includes('youtube.com') ||
  430. urlObj.hostname === 'youtu.be' ||
  431. urlObj.hostname.includes('vimeo.com') ||
  432. urlObj.hostname.includes('streamable.com')
  433. ) {
  434. if (link.dataset.titleProcessed) {
  435. console.log(`[Fullchan X] Skipping already processed link: ${link.href}`);
  436. continue;
  437. }
  438. link.dataset.titleProcessed = 'true';
  439. const originalText = link.textContent;
  440.  
  441. const titlePromise = new Promise((resolve, reject) => {
  442. this.apiQueue.push({
  443. url: link.href,
  444. status: 'queued',
  445. resolve,
  446. reject
  447. });
  448. });
  449. this.processApiQueue();
  450.  
  451. const title = await titlePromise;
  452. if (title !== link.href) {
  453. link.textContent = title;
  454. link.title = originalText;
  455. console.log(`[Fullchan X] Replaced title for ${link.href}: ${title}`);
  456. } else {
  457. console.log(`[Fullchan X] No title change for ${link.href}`);
  458. }
  459. } else {
  460. console.log(`[Fullchan X] Skipped non-supported video link: ${link.href}`);
  461. }
  462. } catch (error) {
  463. console.warn(`[Fullchan X] Error processing video link ${link.href}:`, error);
  464. }
  465. }
  466. } catch (error) {
  467. console.error('[Fullchan X] Error replacing video links:', error);
  468. }
  469. }
  470.  
  471. isTrustedSource(url) {
  472. try {
  473. const urlObj = new URL(url);
  474. const isTrusted = this.trustedDomains.some(domain =>
  475. urlObj.hostname === domain ||
  476. urlObj.hostname.endsWith(`.${domain}`) ||
  477. urlObj.hostname.includes(domain)
  478. );
  479. console.log(`[Fullchan X] Trust check for ${url}: hostname=${urlObj.hostname}, trusted=${isTrusted}`);
  480. return isTrusted;
  481. } catch (e) {
  482. console.warn(`[Fullchan X] Invalid URL: ${url}`);
  483. return false;
  484. }
  485. }
  486.  
  487. addVideoHoverListeners() {
  488. try {
  489. console.log('[Fullchan X] Adding video hover listeners...');
  490. this.videoLinks.forEach(link => {
  491. if (link.dataset.hoverProcessed) return;
  492. link.dataset.hoverProcessed = 'true';
  493. link.tabIndex = 0;
  494.  
  495. let videoElement = null;
  496.  
  497. const createVideoElement = (event) => {
  498. if (!this.isTrustedSource(link.href)) return;
  499.  
  500. if (!videoElement) {
  501. videoElement = document.createElement('video');
  502. videoElement.src = link.href;
  503. videoElement.volume = 0.5;
  504. videoElement.loop = true;
  505. videoElement.style.position = 'fixed';
  506. videoElement.style.zIndex = '1000';
  507. videoElement.style.pointerEvents = 'none';
  508. videoElement.style.maxWidth = this.lowResPreviews ? '480px' : '90vw';
  509. videoElement.style.maxHeight = this.lowResPreviews ? '270px' : '90vh';
  510. document.body.appendChild(videoElement);
  511.  
  512. videoElement.play().catch(error => {
  513. console.error(`[Fullchan X] Video playback failed for ${link.href}:`, error);
  514. });
  515.  
  516. videoElement.addEventListener('loadedmetadata', () => {
  517. videoElement.style.width = this.lowResPreviews ? '480px' : `${videoElement.videoWidth}px`;
  518. videoElement.style.height = this.lowResPreviews ? '270px' : `${videoElement.videoHeight}px`;
  519. this.positionPreviewInViewport(videoElement, event);
  520. });
  521. }
  522.  
  523. this.positionPreviewInViewport(videoElement, event);
  524. };
  525.  
  526. link.addEventListener('mouseenter', createVideoElement);
  527. link.addEventListener('keydown', (event) => {
  528. if (event.key === 'Enter') {
  529. createVideoElement(event);
  530. } else if (event.key === 'Escape' && videoElement) {
  531. videoElement.pause();
  532. videoElement.currentTime = 0;
  533. videoElement.remove();
  534. videoElement = null;
  535. }
  536. });
  537. link.addEventListener('mousemove', (event) => {
  538. if (videoElement) {
  539. this.positionPreviewInViewport(videoElement, event);
  540. }
  541. });
  542. link.addEventListener('mouseleave', () => {
  543. if (videoElement) {
  544. videoElement.pause();
  545. videoElement.currentTime = 0;
  546. videoElement.remove();
  547. videoElement = null;
  548. }
  549. });
  550. link.addEventListener('click', () => {
  551. if (videoElement) {
  552. videoElement.remove();
  553. videoElement = null;
  554. }
  555. });
  556. });
  557. } catch (error) {
  558. console.error('[Fullchan X] Error adding video hover listeners:', error);
  559. }
  560. }
  561.  
  562. addImageHoverListeners() {
  563. try {
  564. console.log('[Fullchan X] Adding image hover listeners...');
  565. this.imageLinks.forEach(link => {
  566. if (link.dataset.hoverProcessed) return;
  567. link.dataset.hoverProcessed = 'true';
  568. link.tabIndex = 0;
  569.  
  570. let imageElement = null;
  571.  
  572. const createImageElement = (event) => {
  573. if (!this.isTrustedSource(link.href)) return;
  574.  
  575. if (!imageElement) {
  576. imageElement = document.createElement('img');
  577. imageElement.src = link.href;
  578. imageElement.style.position = 'fixed';
  579. imageElement.style.zIndex = '1000';
  580. imageElement.style.pointerEvents = 'none';
  581. imageElement.style.maxWidth = '70vw';
  582. imageElement.style.maxHeight = '70vh';
  583. document.body.appendChild(imageElement);
  584.  
  585. this.positionPreviewInViewport(imageElement, event);
  586. }
  587. };
  588.  
  589. link.addEventListener('mouseenter', createImageElement);
  590. link.addEventListener('keydown', (event) => {
  591. if (event.key === 'Enter') {
  592. createImageElement(event);
  593. } else if (event.key === 'Escape' && imageElement) {
  594. imageElement.remove();
  595. imageElement = null;
  596. }
  597. });
  598. link.addEventListener('mousemove', (event) => {
  599. if (imageElement) {
  600. this.positionPreviewInViewport(imageElement, event);
  601. }
  602. });
  603. link.addEventListener('mouseleave', () => {
  604. if (imageElement) {
  605. imageElement.remove();
  606. imageElement = null;
  607. }
  608. });
  609. link.addEventListener('click', () => {
  610. if (imageElement) {
  611. imageElement.remove();
  612. imageElement = null;
  613. }
  614. });
  615. });
  616. } catch (error) {
  617. console.error('[Fullchan X] Error adding image hover listeners:', error);
  618. }
  619. }
  620.  
  621. positionPreviewInViewport(element, event) {
  622. try {
  623. if (!element || !event) return;
  624. const offset = 10;
  625. const topBoundaryMargin = 60;
  626. const sideBoundaryMargin = 10;
  627. const mouseX = event.clientX;
  628. const mouseY = event.clientY;
  629. const viewportWidth = window.innerWidth;
  630. const viewportHeight = window.innerHeight;
  631. const elementRect = element.getBoundingClientRect();
  632. const elementWidth = elementRect.width || (element.videoWidth ? element.videoWidth : 300);
  633. const elementHeight = elementRect.height || (element.videoHeight ? element.videoHeight : 300);
  634.  
  635. let left = mouseX + offset;
  636. let top = mouseY + offset;
  637.  
  638. if (left + elementWidth > viewportWidth - sideBoundaryMargin) {
  639. left = mouseX - elementWidth - offset;
  640. }
  641. if (left < sideBoundaryMargin) {
  642. left = sideBoundaryMargin;
  643. }
  644.  
  645. if (top + elementHeight > viewportHeight - sideBoundaryMargin) {
  646. top = mouseY - elementHeight - offset;
  647. }
  648. if (top < topBoundaryMargin) {
  649. top = topBoundaryMargin;
  650. }
  651.  
  652. element.style.left = `${left}px`;
  653. element.style.top = `${top}px`;
  654. } catch (error) {
  655. console.error('[Fullchan X] Error positioning preview:', error);
  656. }
  657. }
  658.  
  659. addAudioHoverListeners() {
  660. try {
  661. console.log('[Fullchan X] Adding audio hover listeners...');
  662. this.audioLinks.forEach(link => {
  663. if (link.dataset.hoverProcessed) return;
  664. link.dataset.hoverProcessed = 'true';
  665. link.tabIndex = 0;
  666.  
  667. let audioElement = null;
  668.  
  669. const createAudioElement = () => {
  670. if (!this.isTrustedSource(link.href)) return;
  671.  
  672. if (!audioElement) {
  673. audioElement = document.createElement('audio');
  674. audioElement.src = link.href;
  675. audioElement.volume = 0.5;
  676. link.appendChild(audioElement);
  677.  
  678. const indicator = document.createElement('div');
  679. indicator.classList.add('audio-preview-indicator');
  680. indicator.textContent = '▶ Playing audio...';
  681. link.appendChild(indicator);
  682. }
  683.  
  684. audioElement.play().catch(error => {
  685. console.error(`[Fullchan X] Audio playback failed for ${link.href}:`, error);
  686. });
  687. };
  688.  
  689. link.addEventListener('mouseenter', createAudioElement);
  690. link.addEventListener('keydown', (event) => {
  691. if (event.key === 'Enter') {
  692. createAudioElement();
  693. } else if (event.key === 'Escape' && audioElement) {
  694. audioElement.pause();
  695. audioElement.currentTime = 0;
  696. const indicator = link.querySelector('.audio-preview-indicator');
  697. if (indicator) indicator.remove();
  698. audioElement.remove();
  699. audioElement = null;
  700. }
  701. });
  702. link.addEventListener('mouseleave', () => {
  703. if (audioElement) {
  704. audioElement.pause();
  705. audioElement.currentTime = 0;
  706. const indicator = link.querySelector('.audio-preview-indicator');
  707. if (indicator) indicator.remove();
  708. audioElement.remove();
  709. audioElement = null;
  710. }
  711. });
  712. link.addEventListener('click', () => {
  713. if (audioElement) {
  714. audioElement.remove();
  715. audioElement = null;
  716. const indicator = link.querySelector('.audio-preview-indicator');
  717. if (indicator) indicator.remove();
  718. }
  719. });
  720. });
  721. } catch (error) {
  722. console.error('[Fullchan X] Error adding audio hover listeners:', error);
  723. }
  724. }
  725.  
  726. addKeyboardListeners() {
  727. try {
  728. console.log('[Fullchan X] Adding keyboard listeners...');
  729. [...this.videoLinks, ...this.audioLinks, ...this.imageLinks].forEach(link => {
  730. if (!link.tabIndex) {
  731. link.tabIndex = 0;
  732. }
  733. });
  734. } catch (error) {
  735. console.error('[Fullchan X] Error adding keyboard listeners:', error);
  736. }
  737. }
  738.  
  739. addQuickReplyKeyboardHandlers() {
  740. try {
  741. console.log('[Fullchan X] Setting up quick reply keyboard handlers...');
  742. document.addEventListener('keydown', (event) => {
  743. try {
  744. if (event.key === 'Escape' && this.quickReply && getComputedStyle(this.quickReply).display !== 'none') {
  745. this.quickReply.style.display = 'none';
  746. console.log('[Fullchan X] Quick reply closed via Escape.');
  747. }
  748. } catch (error) {
  749. console.error('[Fullchan X] Escape key handler error:', error);
  750. }
  751. });
  752. console.log('[Fullchan X] Escape key listener added.');
  753.  
  754. const qrbody = this.quickReply?.querySelector('#qrbody') || this.quickReply?.querySelector('textarea');
  755. if (!qrbody) {
  756. console.warn('[Fullchan X] #qrbody or textarea not found, skipping spoiler keybind.');
  757. return;
  758. }
  759. console.log('[Fullchan X] #qrbody or textarea found, adding spoiler keybind.');
  760.  
  761. qrbody.addEventListener('keydown', (event) => {
  762. try {
  763. if (event.ctrlKey && event.key === 's') {
  764. event.preventDefault();
  765. const start = qrbody.selectionStart;
  766. const end = qrbody.selectionEnd;
  767. const text = qrbody.value;
  768. const selectedText = text.slice(start, end);
  769.  
  770. const spoilerText = selectedText ? `[spoiler]${selectedText}[/spoiler]` : '[spoiler][/spoiler]';
  771. qrbody.value = text.slice(0, start) + spoilerText + text.slice(end);
  772.  
  773. const newCursorPos = selectedText ? start + 9 + selectedText.length : start + 9;
  774. qrbody.setSelectionRange(newCursorPos, newCursorPos);
  775.  
  776. console.log(`[Fullchan X] Added spoiler tags, cursor at: ${newCursorPos}`);
  777. }
  778. } catch (error) {
  779. console.error('[Fullchan X] Spoiler keybind error:', error);
  780. }
  781. });
  782. console.log('[Fullchan X] Ctrl+S key listener added.');
  783. } catch (error) {
  784. console.error('[Fullchan X] Error adding quick reply handlers:', error);
  785. }
  786. }
  787.  
  788. handleClick(event) {
  789. try {
  790. if (!event.target) return;
  791. const clicked = event.target;
  792. const post = clicked.closest('.innerPost') || clicked.closest('.innerOP');
  793. if (!post) return;
  794.  
  795. const isNested = !!post.closest('.innerNested');
  796. const nestQuote = clicked.closest('a.quoteLink');
  797. const postMedia = clicked.closest('a[data-filemime]');
  798. const postId = clicked.closest('.linkQuote');
  799.  
  800. if (nestQuote) {
  801. event.preventDefault();
  802. event.stopPropagation();
  803. this.nestQuote(nestQuote);
  804. } else if (postMedia && isNested) {
  805. this.handleMediaClick(event, postMedia);
  806. } else if (postId && isNested) {
  807. this.handleIdClick(postId);
  808. }
  809. } catch (error) {
  810. console.error('[Fullchan X] Click handler error:', error);
  811. }
  812. }
  813.  
  814. handleMediaClick(event, postMedia) {
  815. try {
  816. if (!postMedia || !postMedia.dataset.filemime) return;
  817. if (postMedia.dataset.filemime.startsWith('video/') || postMedia.dataset.filemime.startsWith('audio/')) return;
  818. event.preventDefault();
  819. const imageSrc = postMedia.href;
  820. const imageEl = postMedia.querySelector('img');
  821. if (!imageEl) return;
  822.  
  823. if (!postMedia.dataset.thumbSrc) postMedia.dataset.thumbSrc = imageEl.src;
  824. const isExpanding = imageEl.src !== imageSrc;
  825.  
  826. imageEl.src = isExpanding ? imageSrc : postMedia.dataset.thumbSrc;
  827. imageEl.classList.toggle('imgExpanded', isExpanding);
  828. } catch (error) {
  829. console.error('[Fullchan X] Media click error:', error);
  830. }
  831. }
  832.  
  833. handleIdClick(postId) {
  834. try {
  835. if (!postId || !this.quickReply) return;
  836. const idNumber = '>>' + postId.textContent;
  837. this.quickReply.style.display = 'block';
  838. const qrbody = this.quickReply.querySelector('#qrbody') || this.quickReply.querySelector('textarea');
  839. if (qrbody) qrbody.value += idNumber + '\n';
  840. } catch (error) {
  841. console.error('[Fullchan X] ID click error:', error);
  842. }
  843. }
  844.  
  845. assignPostOrder() {
  846. try {
  847. console.log(`[Fullchan X] Assigning post order: ${this.postOrder}`);
  848. const postOrderReplies = (post) => {
  849. const replyCount = post.querySelectorAll('.panelBacklinks a').length;
  850. post.style.order = 100 - replyCount;
  851. };
  852.  
  853. const postOrderCatbox = (post) => {
  854. const postContent = post.querySelector('.divMessage')?.textContent || '';
  855. const matches = postContent.match(/catbox\.moe/g);
  856. const catboxCount = matches ? matches.length : 0;
  857. post.style.order = 100 - catboxCount;
  858. };
  859.  
  860. if (this.postOrder === 'default') {
  861. this.thread.style.display = 'block';
  862. return;
  863. }
  864.  
  865. this.thread.style.display = 'flex';
  866. if (this.postOrder === 'replies') {
  867. this.posts.forEach(post => postOrderReplies(post));
  868. } else if (this.postOrder === 'catbox') {
  869. this.posts.forEach(post => postOrderCatbox(post));
  870. }
  871. } catch (error) {
  872. console.error('[Fullchan X] Post order error:', error);
  873. }
  874. }
  875.  
  876. nestQuote(quoteLink) {
  877. try {
  878. console.log('[Fullchan X] Nesting quote...');
  879. if (!quoteLink) throw new Error('No quoteLink provided.');
  880. const parentPostMessage = quoteLink.closest('.divMessage');
  881. const quoteId = quoteLink.href.split('#')[1];
  882. console.log(`[Fullchan X] Quote ID: ${quoteId}`);
  883. if (!quoteId) throw new Error('Invalid quote ID.');
  884. const quotePost = document.getElementById(quoteId);
  885. if (!quotePost || !parentPostMessage) throw new Error(`Quote post (${quoteId}) or parent message not found.`);
  886.  
  887. const quotePostContent = quotePost.querySelector('.innerPost, .innerOP') || quotePost.querySelector('.postCell, [class*="post"]');
  888. console.log(`[Fullchan X] Quote post content: ${quotePostContent ? 'found' : 'not found'}`);
  889. if (!quotePostContent) throw new Error(`Quote post content not found for ${quoteId}.`);
  890.  
  891. const existing = parentPostMessage.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`);
  892. if (existing) {
  893. existing.remove();
  894. console.log(`[Fullchan X] Removed existing nested quote: ${quoteId}`);
  895. return;
  896. }
  897.  
  898. const wrapper = document.createElement('div');
  899. wrapper.classList.add('nestedPost');
  900. wrapper.setAttribute('data-quote-id', quoteId);
  901.  
  902. const clone = quotePostContent.cloneNode(true);
  903. console.log('[Fullchan X] Cloned post content.');
  904. clone.querySelectorAll('.panelBacklinks, .postControls').forEach(el => el.remove());
  905. clone.style.whiteSpace = 'unset';
  906. clone.classList.add('innerNested');
  907.  
  908. const htmlContent = DOMPurify.sanitize(clone.outerHTML, {
  909. ALLOWED_TAGS: ['div', 'span', 'p', 'a', 'img', 'br', 'strong', 'em', 'blockquote', 'pre', 'code'],
  910. ALLOWED_ATTR: ['href', 'src', 'class', 'style', 'data-filemime', 'data-thumb-src', 'alt', 'title']
  911. });
  912. console.log(`[Fullchan X] Sanitized content length: ${htmlContent.length}`);
  913. if (!htmlContent) throw new Error('Sanitized content is empty.');
  914.  
  915. wrapper.innerHTML = htmlContent;
  916. parentPostMessage.appendChild(wrapper);
  917. console.log(`[Fullchan X] Nested quote added: ${quoteId}`);
  918. } catch (error) {
  919. console.error('[Fullchan X] Failed to create nested quote:', error.message);
  920. }
  921. }
  922. }
  923.  
  924. try {
  925. console.log('[Fullchan X] Defining custom element: fullchan-x');
  926. window.customElements.define('fullchan-x', FullChanX);
  927. console.log('[Fullchan X] Custom element defined successfully.');
  928. } catch (error) {
  929. console.error('[Fullchan X] Failed to define custom element:', error);
  930. }
  931.  
  932. try {
  933. console.log('[Fullchan X] Creating custom element...');
  934. const fcx = document.createElement('fullchan-x');
  935. console.log(`[Fullchan X] Element created, instance of FullChanX: ${fcx instanceof FullChanX}`);
  936. fcx.innerHTML = `
  937. <div class="fcx__controls">
  938. <select id="thread-sort" aria-label="Sort thread posts">
  939. <option value="default">Default</option>
  940. <option value="replies">Replies</option>
  941. <option value="catbox">Catbox</option>
  942. </select>
  943. <label for="low-res-previews" style="margin-top: 5px;">
  944. <input type="checkbox" id="low-res-previews" ${JSON.parse(localStorage.getItem('fullchanx-low-res-previews') || 'false') ? 'checked' : ''}>
  945. Low-Res Video Previews
  946. </label>
  947. <div class="fcx__my-yous" role="region" aria-label="Posts mentioning you">
  948. <p class="my-yous__label">My (You)s</p>
  949. <div class="my-yous__yous" id="my-yous"></div>
  950. </div>
  951. </div>
  952. `;
  953. document.body.appendChild(fcx);
  954. console.log('[Fullchan X] Custom element appended to body.');
  955. if (fcx instanceof FullChanX) {
  956. fcx.init();
  957. console.log('[Fullchan X] Custom element initialized.');
  958. } else {
  959. console.error('[Fullchan X] Element is not a FullChanX instance, skipping init.');
  960. }
  961. } catch (error) {
  962. console.error('[Fullchan X] Failed to create or initialize custom element:', error);
  963. }
  964.  
  965. const style = document.createElement('style');
  966. style.innerHTML = `
  967. fullchan-x {
  968. display: block;
  969. position: fixed;
  970. top: 2.5rem;
  971. right: 2rem;
  972. padding: 10px;
  973. background: var(--contrast-color);
  974. border: 1px solid var(--navbar-text-color);
  975. color: var(--link-color);
  976. font-size: 14px;
  977. opacity: 0.5;
  978. z-index: 1000;
  979. }
  980. fullchan-x:hover { opacity: 1; }
  981. .divPosts { flex-direction: column; }
  982. .fcx__controls { display: flex; flex-direction: column; gap: 5px; }
  983. #thread-sort {
  984. padding: 0.4rem 0.6rem;
  985. background: white;
  986. border: none;
  987. border-radius: 0.2rem;
  988. transition: all ease 150ms;
  989. cursor: pointer;
  990. }
  991. #low-res-previews {
  992. margin-right: 5px;
  993. }
  994. .my-yous__yous { display: none; flex-direction: column; }
  995. .my-yous__label {
  996. padding: 0.4rem 0.6rem;
  997. background: white;
  998. border: none;
  999. border-radius: 0.2rem;
  1000. transition: all ease 150ms;
  1001. cursor: pointer;
  1002. }
  1003. .fcx__my-yous:hover .my-yous__yous { display: flex; }
  1004. .innerPost:has(.quoteLink.you) { border-left: solid #dd003e 6px; }
  1005. .innerPost:has(.youName) { border-left: solid #68b723 6px; }
  1006. .nestedPost {
  1007. max-width: 100%;
  1008. box-sizing: border-box;
  1009. background: var(--contrast-color);
  1010. border-radius: 3px;
  1011. display: block;
  1012. visibility: visible;
  1013. }
  1014. .divMessage .nestedPost {
  1015. display: block;
  1016. white-space: normal;
  1017. overflow-wrap: anywhere;
  1018. margin-top: 0.5em;
  1019. margin-bottom: 0.5em;
  1020. border: 1px solid var(--navbar-text-color);
  1021. padding: 8px;
  1022. }
  1023. .nestedPost .innerNested {
  1024. width: 100%;
  1025. max-width: 100%;
  1026. display: block;
  1027. font-size: 0.95em;
  1028. }
  1029. .nestedPost .divMessage {
  1030. margin: 0;
  1031. padding: 0;
  1032. white-space: normal;
  1033. display: block;
  1034. }
  1035. .nestedPost .postInfo {
  1036. margin-bottom: 5px;
  1037. font-size: 0.9em;
  1038. display: block;
  1039. }
  1040. .nestedPost img, .nestedPost .imgLink img {
  1041. max-width: 100%;
  1042. max-height: 200px;
  1043. height: auto;
  1044. display: block;
  1045. }
  1046. .nestedPost .imgLink .imgExpanded {
  1047. max-width: 100%;
  1048. max-height: 300px;
  1049. width: auto;
  1050. height: auto;
  1051. }
  1052. .nestedPost a[data-filemime] {
  1053. pointer-events: auto;
  1054. display: inline-block;
  1055. }
  1056. .my-yous__label.unseen {
  1057. background: var(--link-hover-color);
  1058. color: white;
  1059. }
  1060. .my-yous__yous .unseen {
  1061. font-weight: 900;
  1062. color: var(--link-hover-color);
  1063. }
  1064. a[data-filemime^="video/"]:hover, a[data-filemime^="image/"]:hover, a[data-filemime^="audio/"]:hover {
  1065. position: relative;
  1066. }
  1067. a[data-filemime]:focus {
  1068. outline: 2px solid var(--link-hover-color);
  1069. }
  1070. .audio-preview-indicator {
  1071. position: absolute;
  1072. background: rgba(0, 0, 0, 0.7);
  1073. color: white;
  1074. padding: 5px;
  1075. font-size: 12px;
  1076. border-radius: 3px;
  1077. z-index: 1000;
  1078. }
  1079. `;
  1080. try {
  1081. document.head.appendChild(style);
  1082. console.log('[Fullchan X] Styles appended to head.');
  1083. } catch (error) {
  1084. console.error('[Fullchan X] Failed to append styles:', error);
  1085. }
Add Comment
Please, Sign In to add comment