Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // ==UserScript==
- // @name Fullchan X grok edition
- // @namespace Violentmonkey Scripts
- // @match https://8chan.moe/*/res/*.html*
- // @match https://8chan.se/*/res/*.html*
- // @grant none
- // @version 1.4.15 // Fixed unseen (You)s marking all posts
- // @author stolen from vfyxe, modified by grok, security fixes and enhancements by Grok 3
- // @description credits to vfyxe //////https://greasyfork.org/en/scripts/533067-fullchan-x//////
- // @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).
- // @description make sure to disable image preview hover in native settings to avoid video playback issues. Install via Violentmonkey.
- // ==/UserScript==
- // Exit if not a thread page
- const divPosts = document.querySelector('.divPosts');
- if (!divPosts) {
- console.warn('[Fullchan X] No .divPosts found, exiting.');
- return;
- }
- // DOMPurify: Include full DOMPurify.min.js from https://github.com/cure53/DOMPurify/releases/tag/3.1.6
- // Example: <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"></script>
- (function() {
- if (!window.DOMPurify) {
- window.DOMPurify = {
- sanitize: function(html) {
- console.log('[Fullchan X] Sanitizing HTML with placeholder...');
- const div = document.createElement('div');
- div.innerHTML = html;
- const unsafeAttrs = ['onclick', 'onload', 'onerror', 'onmouseover', 'onmouseout'];
- div.querySelectorAll('script').forEach(el => el.remove());
- div.querySelectorAll('*').forEach(el => {
- unsafeAttrs.forEach(attr => el.removeAttribute(attr));
- });
- const sanitized = div.innerHTML;
- console.log('[Fullchan X] Sanitization complete:', sanitized.slice(0, 100) + (sanitized.length > 100 ? '...' : ''));
- return sanitized;
- }
- };
- console.warn('[Fullchan X] Using placeholder DOMPurify. Include full DOMPurify.min.js for production.');
- } else {
- console.log('[Fullchan X] DOMPurify loaded.');
- }
- })();
- class FullChanX extends HTMLElement {
- constructor() {
- super();
- this.enableNestedQuotes = true;
- this.videoLinks = [];
- this.audioLinks = [];
- this.imageLinks = [];
- this.trustedDomains = [
- '8chan.moe',
- '8chan.se',
- 'catbox.moe',
- 'imgur.com',
- 'youtube.com',
- 'youtu.be',
- 'vimeo.com',
- 'giphy.com',
- 'streamable.com'
- ];
- this.lowResPreviews = JSON.parse(localStorage.getItem('fullchanx-low-res-previews') || 'false');
- this.apiQueue = [];
- this.maxConcurrentRequests = 3;
- this.requestDelay = 100;
- this.seenYous = [];
- this.unseenYous = [];
- }
- init() {
- try {
- console.log('[Fullchan X] Starting initialization...');
- this.quickReply = document.querySelector('#quick-reply') || document.querySelector('form[id*="reply"], form');
- console.log(`[Fullchan X] Quick reply: ${this.quickReply ? 'found' : 'not found'}`);
- this.threadParent = document.querySelector('#divThreads') || document.querySelector('[id*="thread"]');
- console.log(`[Fullchan X] Thread parent: ${this.threadParent ? 'found' : 'not found'}`);
- if (!this.threadParent) {
- console.error('[Fullchan X] Thread parent not found, exiting init.');
- return;
- }
- this.threadId = this.threadParent.querySelector('.opCell')?.id;
- console.log(`[Fullchan X] Thread ID: ${this.threadId || 'not found'}`);
- this.thread = this.threadParent.querySelector('.divPosts');
- console.log(`[Fullchan X] Thread (.divPosts): ${this.thread ? 'found' : 'not found'}`);
- if (!this.thread || !this.threadId) {
- console.error('[Fullchan X] Thread or thread ID not found, exiting init.');
- return;
- }
- this.posts = [...this.thread.querySelectorAll('.postCell, [class*="post"]')];
- console.log(`[Fullchan X] Found ${this.posts.length} posts`);
- const youLinks = this.thread.querySelectorAll('.quoteLink.you, .quoteLink[id*="you"]');
- console.log(`[Fullchan X] Found ${youLinks.length} .quoteLink.you elements`);
- this.postOrder = 'default';
- this.postOrderSelect = this.querySelector('#thread-sort');
- console.log(`[Fullchan X] Thread sort select: ${this.postOrderSelect ? 'found' : 'not found'}`);
- this.myYousLabel = this.querySelector('.my-yous__label');
- this.yousContainer = this.querySelector('#my-yous');
- console.log(`[Fullchan X] My (You)s container: ${this.yousContainer ? 'found' : 'not found'}`);
- if (!this.yousContainer || !this.myYousLabel) {
- console.warn('[Fullchan X] #my-yous or .my-yous__label missing, (You)s may not display.');
- }
- this.updateYous();
- this.updateVideoLinks();
- this.updateAudioLinks();
- this.updateImageLinks();
- this.observers();
- this.addVideoHoverListeners();
- this.addAudioHoverListeners();
- this.addImageHoverListeners();
- this.addKeyboardListeners();
- this.addQuickReplyKeyboardHandlers();
- console.log('[Fullchan X] Initialized successfully.');
- } catch (error) {
- console.error('[Fullchan X] Initialization failed:', error);
- }
- }
- observers() {
- try {
- console.log('[Fullchan X] Setting up observers...');
- if (!this.postOrderSelect) {
- console.error('[Fullchan X] Post order select not found, skipping sort listener.');
- } else {
- this.postOrderSelect.addEventListener('change', (event) => {
- this.postOrder = event.target.value || 'default';
- this.assignPostOrder();
- console.log(`[Fullchan X] Post order changed to: ${this.postOrder}`);
- });
- }
- const lowResCheckbox = this.querySelector('#low-res-previews');
- if (lowResCheckbox) {
- lowResCheckbox.addEventListener('change', (event) => {
- this.lowResPreviews = event.target.checked;
- localStorage.setItem('fullchanx-low-res-previews', JSON.stringify(this.lowResPreviews));
- console.log(`[Fullchan X] Low-res previews: ${this.lowResPreviews}`);
- });
- } else {
- console.warn('[Fullchan X] Low-res checkbox not found.');
- }
- let debounceTimeout;
- const observerCallback = (mutationsList) => {
- console.log('[Fullchan X] MutationObserver triggered');
- clearTimeout(debounceTimeout);
- debounceTimeout = setTimeout(() => {
- try {
- for (const mutation of mutationsList) {
- if (mutation.type === 'childList') {
- this.posts = [...this.thread.querySelectorAll('.postCell, [class*="post"]')];
- console.log(`[Fullchan X] Updated posts: ${this.posts.length}`);
- if (this.postOrder !== 'default') this.assignPostOrder();
- this.updateYous();
- this.updateVideoLinks();
- this.updateAudioLinks();
- this.updateImageLinks();
- this.addVideoHoverListeners();
- this.addAudioHoverListeners();
- this.addImageHoverListeners();
- this.addKeyboardListeners();
- }
- }
- } catch (error) {
- console.error('[Fullchan X] Observer error:', error);
- }
- }, 100);
- };
- const threadObserver = new MutationObserver(observerCallback);
- threadObserver.observe(this.thread, { childList: true, subtree: true });
- console.log('[Fullchan X] MutationObserver set up.');
- if (this.enableNestedQuotes) {
- this.thread.addEventListener('click', (event) => this.handleClick(event));
- console.log('[Fullchan X] Nested quote click listener added.');
- }
- } catch (error) {
- console.error('[Fullchan X] Observers setup failed:', error);
- }
- }
- updateYous() {
- try {
- console.log('[Fullchan X] Updating (You)s...');
- this.yous = this.posts.filter(post => {
- console.log(`[Fullchan X] Checking for .quoteLink.you in post: id=${post.id}`);
- const youLink = post.querySelector('.quoteLink.you, .quoteLink[id*="you"]');
- if (youLink) {
- console.log(`[Fullchan X] Matched (You) in post: id=${post.id}`);
- return true;
- }
- console.log(`[Fullchan X] No (You) found in post: id=${post.id}`);
- return false;
- });
- console.log(`[Fullchan X] Found ${this.yous.length} (You)s`);
- this.yousLinks = this.yous.map(you => {
- const youLink = document.createElement('a');
- youLink.textContent = '>>' + you.id;
- youLink.href = '#' + you.id;
- return youLink;
- });
- this.setUnseenYous();
- if (this.yousContainer) {
- this.yousContainer.innerHTML = '';
- this.yousLinks.forEach(you => {
- const youId = you.textContent.replace('>>', '');
- if (!this.seenYous.includes(youId)) {
- you.classList.add('unseen');
- }
- this.yousContainer.appendChild(you);
- });
- console.log('[Fullchan X] Updated #my-yous with links.');
- } else {
- console.warn('[Fullchan X] #my-yous container not found.');
- }
- if (this.myYousLabel) {
- this.myYousLabel.classList.toggle('unseen', this.unseenYous.length > 0);
- console.log(`[Fullchan X] Updated .my-yous__label, unseen: ${this.unseenYous.length > 0}`);
- } else {
- console.warn('[Fullchan X] .my-yous__label not found.');
- }
- } catch (error) {
- console.error('[Fullchan X] Error updating (You)s:', error);
- }
- }
- setUnseenYous() {
- try {
- console.log('[Fullchan X] Setting unseen (You)s...');
- this.seenKey = `fullchanx-${this.threadId}-seen-yous`;
- let storedYous = [];
- try {
- const stored = localStorage.getItem(this.seenKey);
- if (stored) {
- storedYous = JSON.parse(stored);
- if (!Array.isArray(storedYous)) {
- console.warn('[Fullchan X] Invalid seenYous format, resetting to []');
- storedYous = [];
- localStorage.setItem(this.seenKey, JSON.stringify(storedYous));
- console.log('[Fullchan X] Cleared invalid seenYous data.');
- }
- }
- console.log(`[Fullchan X] Loaded seenYous: ${storedYous.length}`);
- } catch (e) {
- console.warn(`[Fullchan X] Error reading seenYous: ${e.message}, using []`);
- storedYous = [];
- localStorage.setItem(this.seenKey, JSON.stringify(storedYous));
- console.log('[Fullchan X] Initialized seenYous to [] due to error.');
- }
- this.seenYous = storedYous;
- this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id));
- console.log(`[Fullchan X] Unseen (You)s: ${this.unseenYous.length}`);
- this.unseenYous.forEach(you => {
- if (!you.classList.contains('observe-you')) {
- this.observeUnseenYou(you);
- }
- });
- const hasUnseenYous = this.unseenYous.length > 0;
- document.title = hasUnseenYous
- ? document.title.startsWith('🔴 ') ? document.title : `🔴 ${document.title}`
- : document.title.replace(/^🔴 /, '');
- console.log(`[Fullchan X] Title updated, hasUnseenYous: ${hasUnseenYous}`);
- } catch (error) {
- console.error('[Fullchan X] Error setting unseen (You)s:', error);
- }
- }
- observeUnseenYou(you) {
- try {
- if (!you) return;
- you.classList.add('observe-you');
- console.log(`[Fullchan X] Observing unseen (You): id=${you.id}`);
- const observer = new IntersectionObserver((entries, observer) => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- const id = you.id;
- you.classList.remove('observe-you');
- console.log(`[Fullchan X] Marked (You) as seen: id=${id}`);
- if (!this.seenYous.includes(id)) {
- this.seenYous.push(id);
- try {
- localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
- console.log(`[Fullchan X] Saved seenYous: ${this.seenYous.length}`);
- } catch (e) {
- console.warn(`[Fullchan X] Failed to save seenYous: ${e.message}`);
- }
- }
- observer.unobserve(you);
- console.log(`[Fullchan X] Stopped observing: id=${id}`);
- this.updateYous();
- }
- });
- }, { rootMargin: '0px', threshold: 0.1 });
- observer.observe(you);
- } catch (error) {
- console.error('[Fullchan X] Error observing unseen (You):', error);
- }
- }
- updateVideoLinks() {
- try {
- console.log('[Fullchan X] Updating video links...');
- const links = [
- ...this.thread.querySelectorAll('a[data-filemime^="video/"]'),
- ...this.thread.querySelectorAll('a[href*="youtube.com/"], a[href*="youtu.be/"], a[href*="vimeo.com/"], a[href*="streamable.com/"]')
- ];
- this.videoLinks = [...new Set(links)];
- console.log(`[Fullchan X] Found ${this.videoLinks.length} video links:`, this.videoLinks.map(l => l.href));
- this.replaceVideoLinks();
- } catch (error) {
- console.error('[Fullchan X] Error updating video links:', error);
- }
- }
- updateAudioLinks() {
- try {
- this.audioLinks = [...this.thread.querySelectorAll('a[data-filemime^="audio/"]')];
- console.log(`[Fullchan X] Found ${this.audioLinks.length} audio links`);
- } catch (error) {
- console.error('[Fullchan X] Error updating audio links:', error);
- }
- }
- updateImageLinks() {
- try {
- this.imageLinks = [...this.thread.querySelectorAll('a[data-filemime^="image/"]')];
- console.log(`[Fullchan X] Found ${this.imageLinks.length} image links`);
- } catch (error) {
- console.error('[Fullchan X] Error updating image links:', error);
- }
- }
- async fetchVideoTitle(url) {
- try {
- const urlObj = new URL(url);
- let oEmbedUrl;
- if (urlObj.hostname.includes('youtube.com') || urlObj.hostname === 'youtu.be') {
- oEmbedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
- } else if (urlObj.hostname.includes('vimeo.com')) {
- oEmbedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(url)}`;
- } else if (urlObj.hostname.includes('streamable.com')) {
- oEmbedUrl = `https://api.streamable.com/oembed.json?url=${encodeURIComponent(url)}`;
- } else {
- console.log(`[Fullchan X] Skipping non-supported video URL: ${url}`);
- return url;
- }
- console.log(`[Fullchan X] Fetching title for ${url} via ${oEmbedUrl}`);
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000);
- const response = await fetch(oEmbedUrl, { signal: controller.signal });
- clearTimeout(timeoutId);
- if (!response.ok) {
- throw new Error(`HTTP ${response.status} for ${oEmbedUrl}`);
- }
- const data = await response.json();
- const title = data.title || url;
- console.log(`[Fullchan X] Title fetched: ${title}`);
- return title;
- } catch (error) {
- console.warn(`[Fullchan X] Failed to fetch title for ${url}:`, error.message);
- return url;
- }
- }
- async processApiQueue() {
- try {
- console.log('[Fullchan X] Processing API queue...');
- while (this.apiQueue.length > 0) {
- const activeRequests = this.apiQueue.filter(req => req.status === 'pending').length;
- if (activeRequests < this.maxConcurrentRequests) {
- const request = this.apiQueue.find(req => req.status === 'queued');
- if (request) {
- request.status = 'pending';
- try {
- const title = await this.fetchVideoTitle(request.url);
- request.resolve(title);
- } catch (error) {
- request.reject(error);
- }
- request.status = 'completed';
- this.apiQueue = this.apiQueue.filter(req => req.status !== 'completed');
- }
- }
- await new Promise(resolve => setTimeout(resolve, this.requestDelay));
- }
- console.log('[Fullchan X] API Queue cleared.');
- } catch (error) {
- console.error('[Fullchan X] API Queue error:', error);
- }
- }
- async replaceVideoLinks() {
- try {
- console.log('[Fullchan X] Replacing video link titles...');
- for (const link of this.videoLinks) {
- try {
- const isTrusted = this.isTrustedSource(link.href);
- console.log(`[Fullchan X] Checking trust for ${link.href}: ${isTrusted}`);
- if (!isTrusted) {
- console.log(`[Fullchan X] Skipped untrusted video link: ${link.href}`);
- continue;
- }
- const urlObj = new URL(link.href);
- if (
- urlObj.hostname.includes('youtube.com') ||
- urlObj.hostname === 'youtu.be' ||
- urlObj.hostname.includes('vimeo.com') ||
- urlObj.hostname.includes('streamable.com')
- ) {
- if (link.dataset.titleProcessed) {
- console.log(`[Fullchan X] Skipping already processed link: ${link.href}`);
- continue;
- }
- link.dataset.titleProcessed = 'true';
- const originalText = link.textContent;
- const titlePromise = new Promise((resolve, reject) => {
- this.apiQueue.push({
- url: link.href,
- status: 'queued',
- resolve,
- reject
- });
- });
- this.processApiQueue();
- const title = await titlePromise;
- if (title !== link.href) {
- link.textContent = title;
- link.title = originalText;
- console.log(`[Fullchan X] Replaced title for ${link.href}: ${title}`);
- } else {
- console.log(`[Fullchan X] No title change for ${link.href}`);
- }
- } else {
- console.log(`[Fullchan X] Skipped non-supported video link: ${link.href}`);
- }
- } catch (error) {
- console.warn(`[Fullchan X] Error processing video link ${link.href}:`, error);
- }
- }
- } catch (error) {
- console.error('[Fullchan X] Error replacing video links:', error);
- }
- }
- isTrustedSource(url) {
- try {
- const urlObj = new URL(url);
- const isTrusted = this.trustedDomains.some(domain =>
- urlObj.hostname === domain ||
- urlObj.hostname.endsWith(`.${domain}`) ||
- urlObj.hostname.includes(domain)
- );
- console.log(`[Fullchan X] Trust check for ${url}: hostname=${urlObj.hostname}, trusted=${isTrusted}`);
- return isTrusted;
- } catch (e) {
- console.warn(`[Fullchan X] Invalid URL: ${url}`);
- return false;
- }
- }
- addVideoHoverListeners() {
- try {
- console.log('[Fullchan X] Adding video hover listeners...');
- this.videoLinks.forEach(link => {
- if (link.dataset.hoverProcessed) return;
- link.dataset.hoverProcessed = 'true';
- link.tabIndex = 0;
- let videoElement = null;
- const createVideoElement = (event) => {
- if (!this.isTrustedSource(link.href)) return;
- if (!videoElement) {
- videoElement = document.createElement('video');
- videoElement.src = link.href;
- videoElement.volume = 0.5;
- videoElement.loop = true;
- videoElement.style.position = 'fixed';
- videoElement.style.zIndex = '1000';
- videoElement.style.pointerEvents = 'none';
- videoElement.style.maxWidth = this.lowResPreviews ? '480px' : '90vw';
- videoElement.style.maxHeight = this.lowResPreviews ? '270px' : '90vh';
- document.body.appendChild(videoElement);
- videoElement.play().catch(error => {
- console.error(`[Fullchan X] Video playback failed for ${link.href}:`, error);
- });
- videoElement.addEventListener('loadedmetadata', () => {
- videoElement.style.width = this.lowResPreviews ? '480px' : `${videoElement.videoWidth}px`;
- videoElement.style.height = this.lowResPreviews ? '270px' : `${videoElement.videoHeight}px`;
- this.positionPreviewInViewport(videoElement, event);
- });
- }
- this.positionPreviewInViewport(videoElement, event);
- };
- link.addEventListener('mouseenter', createVideoElement);
- link.addEventListener('keydown', (event) => {
- if (event.key === 'Enter') {
- createVideoElement(event);
- } else if (event.key === 'Escape' && videoElement) {
- videoElement.pause();
- videoElement.currentTime = 0;
- videoElement.remove();
- videoElement = null;
- }
- });
- link.addEventListener('mousemove', (event) => {
- if (videoElement) {
- this.positionPreviewInViewport(videoElement, event);
- }
- });
- link.addEventListener('mouseleave', () => {
- if (videoElement) {
- videoElement.pause();
- videoElement.currentTime = 0;
- videoElement.remove();
- videoElement = null;
- }
- });
- link.addEventListener('click', () => {
- if (videoElement) {
- videoElement.remove();
- videoElement = null;
- }
- });
- });
- } catch (error) {
- console.error('[Fullchan X] Error adding video hover listeners:', error);
- }
- }
- addImageHoverListeners() {
- try {
- console.log('[Fullchan X] Adding image hover listeners...');
- this.imageLinks.forEach(link => {
- if (link.dataset.hoverProcessed) return;
- link.dataset.hoverProcessed = 'true';
- link.tabIndex = 0;
- let imageElement = null;
- const createImageElement = (event) => {
- if (!this.isTrustedSource(link.href)) return;
- if (!imageElement) {
- imageElement = document.createElement('img');
- imageElement.src = link.href;
- imageElement.style.position = 'fixed';
- imageElement.style.zIndex = '1000';
- imageElement.style.pointerEvents = 'none';
- imageElement.style.maxWidth = '70vw';
- imageElement.style.maxHeight = '70vh';
- document.body.appendChild(imageElement);
- this.positionPreviewInViewport(imageElement, event);
- }
- };
- link.addEventListener('mouseenter', createImageElement);
- link.addEventListener('keydown', (event) => {
- if (event.key === 'Enter') {
- createImageElement(event);
- } else if (event.key === 'Escape' && imageElement) {
- imageElement.remove();
- imageElement = null;
- }
- });
- link.addEventListener('mousemove', (event) => {
- if (imageElement) {
- this.positionPreviewInViewport(imageElement, event);
- }
- });
- link.addEventListener('mouseleave', () => {
- if (imageElement) {
- imageElement.remove();
- imageElement = null;
- }
- });
- link.addEventListener('click', () => {
- if (imageElement) {
- imageElement.remove();
- imageElement = null;
- }
- });
- });
- } catch (error) {
- console.error('[Fullchan X] Error adding image hover listeners:', error);
- }
- }
- positionPreviewInViewport(element, event) {
- try {
- if (!element || !event) return;
- const offset = 10;
- const topBoundaryMargin = 60;
- const sideBoundaryMargin = 10;
- const mouseX = event.clientX;
- const mouseY = event.clientY;
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
- const elementRect = element.getBoundingClientRect();
- const elementWidth = elementRect.width || (element.videoWidth ? element.videoWidth : 300);
- const elementHeight = elementRect.height || (element.videoHeight ? element.videoHeight : 300);
- let left = mouseX + offset;
- let top = mouseY + offset;
- if (left + elementWidth > viewportWidth - sideBoundaryMargin) {
- left = mouseX - elementWidth - offset;
- }
- if (left < sideBoundaryMargin) {
- left = sideBoundaryMargin;
- }
- if (top + elementHeight > viewportHeight - sideBoundaryMargin) {
- top = mouseY - elementHeight - offset;
- }
- if (top < topBoundaryMargin) {
- top = topBoundaryMargin;
- }
- element.style.left = `${left}px`;
- element.style.top = `${top}px`;
- } catch (error) {
- console.error('[Fullchan X] Error positioning preview:', error);
- }
- }
- addAudioHoverListeners() {
- try {
- console.log('[Fullchan X] Adding audio hover listeners...');
- this.audioLinks.forEach(link => {
- if (link.dataset.hoverProcessed) return;
- link.dataset.hoverProcessed = 'true';
- link.tabIndex = 0;
- let audioElement = null;
- const createAudioElement = () => {
- if (!this.isTrustedSource(link.href)) return;
- if (!audioElement) {
- audioElement = document.createElement('audio');
- audioElement.src = link.href;
- audioElement.volume = 0.5;
- link.appendChild(audioElement);
- const indicator = document.createElement('div');
- indicator.classList.add('audio-preview-indicator');
- indicator.textContent = '▶ Playing audio...';
- link.appendChild(indicator);
- }
- audioElement.play().catch(error => {
- console.error(`[Fullchan X] Audio playback failed for ${link.href}:`, error);
- });
- };
- link.addEventListener('mouseenter', createAudioElement);
- link.addEventListener('keydown', (event) => {
- if (event.key === 'Enter') {
- createAudioElement();
- } else if (event.key === 'Escape' && audioElement) {
- audioElement.pause();
- audioElement.currentTime = 0;
- const indicator = link.querySelector('.audio-preview-indicator');
- if (indicator) indicator.remove();
- audioElement.remove();
- audioElement = null;
- }
- });
- link.addEventListener('mouseleave', () => {
- if (audioElement) {
- audioElement.pause();
- audioElement.currentTime = 0;
- const indicator = link.querySelector('.audio-preview-indicator');
- if (indicator) indicator.remove();
- audioElement.remove();
- audioElement = null;
- }
- });
- link.addEventListener('click', () => {
- if (audioElement) {
- audioElement.remove();
- audioElement = null;
- const indicator = link.querySelector('.audio-preview-indicator');
- if (indicator) indicator.remove();
- }
- });
- });
- } catch (error) {
- console.error('[Fullchan X] Error adding audio hover listeners:', error);
- }
- }
- addKeyboardListeners() {
- try {
- console.log('[Fullchan X] Adding keyboard listeners...');
- [...this.videoLinks, ...this.audioLinks, ...this.imageLinks].forEach(link => {
- if (!link.tabIndex) {
- link.tabIndex = 0;
- }
- });
- } catch (error) {
- console.error('[Fullchan X] Error adding keyboard listeners:', error);
- }
- }
- addQuickReplyKeyboardHandlers() {
- try {
- console.log('[Fullchan X] Setting up quick reply keyboard handlers...');
- document.addEventListener('keydown', (event) => {
- try {
- if (event.key === 'Escape' && this.quickReply && getComputedStyle(this.quickReply).display !== 'none') {
- this.quickReply.style.display = 'none';
- console.log('[Fullchan X] Quick reply closed via Escape.');
- }
- } catch (error) {
- console.error('[Fullchan X] Escape key handler error:', error);
- }
- });
- console.log('[Fullchan X] Escape key listener added.');
- const qrbody = this.quickReply?.querySelector('#qrbody') || this.quickReply?.querySelector('textarea');
- if (!qrbody) {
- console.warn('[Fullchan X] #qrbody or textarea not found, skipping spoiler keybind.');
- return;
- }
- console.log('[Fullchan X] #qrbody or textarea found, adding spoiler keybind.');
- qrbody.addEventListener('keydown', (event) => {
- try {
- if (event.ctrlKey && event.key === 's') {
- event.preventDefault();
- const start = qrbody.selectionStart;
- const end = qrbody.selectionEnd;
- const text = qrbody.value;
- const selectedText = text.slice(start, end);
- const spoilerText = selectedText ? `[spoiler]${selectedText}[/spoiler]` : '[spoiler][/spoiler]';
- qrbody.value = text.slice(0, start) + spoilerText + text.slice(end);
- const newCursorPos = selectedText ? start + 9 + selectedText.length : start + 9;
- qrbody.setSelectionRange(newCursorPos, newCursorPos);
- console.log(`[Fullchan X] Added spoiler tags, cursor at: ${newCursorPos}`);
- }
- } catch (error) {
- console.error('[Fullchan X] Spoiler keybind error:', error);
- }
- });
- console.log('[Fullchan X] Ctrl+S key listener added.');
- } catch (error) {
- console.error('[Fullchan X] Error adding quick reply handlers:', error);
- }
- }
- handleClick(event) {
- try {
- if (!event.target) return;
- const clicked = event.target;
- const post = clicked.closest('.innerPost') || clicked.closest('.innerOP');
- if (!post) return;
- const isNested = !!post.closest('.innerNested');
- const nestQuote = clicked.closest('a.quoteLink');
- const postMedia = clicked.closest('a[data-filemime]');
- const postId = clicked.closest('.linkQuote');
- if (nestQuote) {
- event.preventDefault();
- event.stopPropagation();
- this.nestQuote(nestQuote);
- } else if (postMedia && isNested) {
- this.handleMediaClick(event, postMedia);
- } else if (postId && isNested) {
- this.handleIdClick(postId);
- }
- } catch (error) {
- console.error('[Fullchan X] Click handler error:', error);
- }
- }
- handleMediaClick(event, postMedia) {
- try {
- if (!postMedia || !postMedia.dataset.filemime) return;
- if (postMedia.dataset.filemime.startsWith('video/') || postMedia.dataset.filemime.startsWith('audio/')) return;
- event.preventDefault();
- const imageSrc = postMedia.href;
- const imageEl = postMedia.querySelector('img');
- if (!imageEl) return;
- if (!postMedia.dataset.thumbSrc) postMedia.dataset.thumbSrc = imageEl.src;
- const isExpanding = imageEl.src !== imageSrc;
- imageEl.src = isExpanding ? imageSrc : postMedia.dataset.thumbSrc;
- imageEl.classList.toggle('imgExpanded', isExpanding);
- } catch (error) {
- console.error('[Fullchan X] Media click error:', error);
- }
- }
- handleIdClick(postId) {
- try {
- if (!postId || !this.quickReply) return;
- const idNumber = '>>' + postId.textContent;
- this.quickReply.style.display = 'block';
- const qrbody = this.quickReply.querySelector('#qrbody') || this.quickReply.querySelector('textarea');
- if (qrbody) qrbody.value += idNumber + '\n';
- } catch (error) {
- console.error('[Fullchan X] ID click error:', error);
- }
- }
- assignPostOrder() {
- try {
- console.log(`[Fullchan X] Assigning post order: ${this.postOrder}`);
- const postOrderReplies = (post) => {
- const replyCount = post.querySelectorAll('.panelBacklinks a').length;
- post.style.order = 100 - replyCount;
- };
- const postOrderCatbox = (post) => {
- const postContent = post.querySelector('.divMessage')?.textContent || '';
- const matches = postContent.match(/catbox\.moe/g);
- const catboxCount = matches ? matches.length : 0;
- post.style.order = 100 - catboxCount;
- };
- if (this.postOrder === 'default') {
- this.thread.style.display = 'block';
- return;
- }
- this.thread.style.display = 'flex';
- if (this.postOrder === 'replies') {
- this.posts.forEach(post => postOrderReplies(post));
- } else if (this.postOrder === 'catbox') {
- this.posts.forEach(post => postOrderCatbox(post));
- }
- } catch (error) {
- console.error('[Fullchan X] Post order error:', error);
- }
- }
- nestQuote(quoteLink) {
- try {
- console.log('[Fullchan X] Nesting quote...');
- if (!quoteLink) throw new Error('No quoteLink provided.');
- const parentPostMessage = quoteLink.closest('.divMessage');
- const quoteId = quoteLink.href.split('#')[1];
- console.log(`[Fullchan X] Quote ID: ${quoteId}`);
- if (!quoteId) throw new Error('Invalid quote ID.');
- const quotePost = document.getElementById(quoteId);
- if (!quotePost || !parentPostMessage) throw new Error(`Quote post (${quoteId}) or parent message not found.`);
- const quotePostContent = quotePost.querySelector('.innerPost, .innerOP') || quotePost.querySelector('.postCell, [class*="post"]');
- console.log(`[Fullchan X] Quote post content: ${quotePostContent ? 'found' : 'not found'}`);
- if (!quotePostContent) throw new Error(`Quote post content not found for ${quoteId}.`);
- const existing = parentPostMessage.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`);
- if (existing) {
- existing.remove();
- console.log(`[Fullchan X] Removed existing nested quote: ${quoteId}`);
- return;
- }
- const wrapper = document.createElement('div');
- wrapper.classList.add('nestedPost');
- wrapper.setAttribute('data-quote-id', quoteId);
- const clone = quotePostContent.cloneNode(true);
- console.log('[Fullchan X] Cloned post content.');
- clone.querySelectorAll('.panelBacklinks, .postControls').forEach(el => el.remove());
- clone.style.whiteSpace = 'unset';
- clone.classList.add('innerNested');
- const htmlContent = DOMPurify.sanitize(clone.outerHTML, {
- ALLOWED_TAGS: ['div', 'span', 'p', 'a', 'img', 'br', 'strong', 'em', 'blockquote', 'pre', 'code'],
- ALLOWED_ATTR: ['href', 'src', 'class', 'style', 'data-filemime', 'data-thumb-src', 'alt', 'title']
- });
- console.log(`[Fullchan X] Sanitized content length: ${htmlContent.length}`);
- if (!htmlContent) throw new Error('Sanitized content is empty.');
- wrapper.innerHTML = htmlContent;
- parentPostMessage.appendChild(wrapper);
- console.log(`[Fullchan X] Nested quote added: ${quoteId}`);
- } catch (error) {
- console.error('[Fullchan X] Failed to create nested quote:', error.message);
- }
- }
- }
- try {
- console.log('[Fullchan X] Defining custom element: fullchan-x');
- window.customElements.define('fullchan-x', FullChanX);
- console.log('[Fullchan X] Custom element defined successfully.');
- } catch (error) {
- console.error('[Fullchan X] Failed to define custom element:', error);
- }
- try {
- console.log('[Fullchan X] Creating custom element...');
- const fcx = document.createElement('fullchan-x');
- console.log(`[Fullchan X] Element created, instance of FullChanX: ${fcx instanceof FullChanX}`);
- fcx.innerHTML = `
- <div class="fcx__controls">
- <select id="thread-sort" aria-label="Sort thread posts">
- <option value="default">Default</option>
- <option value="replies">Replies</option>
- <option value="catbox">Catbox</option>
- </select>
- <label for="low-res-previews" style="margin-top: 5px;">
- <input type="checkbox" id="low-res-previews" ${JSON.parse(localStorage.getItem('fullchanx-low-res-previews') || 'false') ? 'checked' : ''}>
- Low-Res Video Previews
- </label>
- <div class="fcx__my-yous" role="region" aria-label="Posts mentioning you">
- <p class="my-yous__label">My (You)s</p>
- <div class="my-yous__yous" id="my-yous"></div>
- </div>
- </div>
- `;
- document.body.appendChild(fcx);
- console.log('[Fullchan X] Custom element appended to body.');
- if (fcx instanceof FullChanX) {
- fcx.init();
- console.log('[Fullchan X] Custom element initialized.');
- } else {
- console.error('[Fullchan X] Element is not a FullChanX instance, skipping init.');
- }
- } catch (error) {
- console.error('[Fullchan X] Failed to create or initialize custom element:', error);
- }
- const style = document.createElement('style');
- style.innerHTML = `
- fullchan-x {
- display: block;
- position: fixed;
- top: 2.5rem;
- right: 2rem;
- padding: 10px;
- background: var(--contrast-color);
- border: 1px solid var(--navbar-text-color);
- color: var(--link-color);
- font-size: 14px;
- opacity: 0.5;
- z-index: 1000;
- }
- fullchan-x:hover { opacity: 1; }
- .divPosts { flex-direction: column; }
- .fcx__controls { display: flex; flex-direction: column; gap: 5px; }
- #thread-sort {
- padding: 0.4rem 0.6rem;
- background: white;
- border: none;
- border-radius: 0.2rem;
- transition: all ease 150ms;
- cursor: pointer;
- }
- #low-res-previews {
- margin-right: 5px;
- }
- .my-yous__yous { display: none; flex-direction: column; }
- .my-yous__label {
- padding: 0.4rem 0.6rem;
- background: white;
- border: none;
- border-radius: 0.2rem;
- transition: all ease 150ms;
- cursor: pointer;
- }
- .fcx__my-yous:hover .my-yous__yous { display: flex; }
- .innerPost:has(.quoteLink.you) { border-left: solid #dd003e 6px; }
- .innerPost:has(.youName) { border-left: solid #68b723 6px; }
- .nestedPost {
- max-width: 100%;
- box-sizing: border-box;
- background: var(--contrast-color);
- border-radius: 3px;
- display: block;
- visibility: visible;
- }
- .divMessage .nestedPost {
- display: block;
- white-space: normal;
- overflow-wrap: anywhere;
- margin-top: 0.5em;
- margin-bottom: 0.5em;
- border: 1px solid var(--navbar-text-color);
- padding: 8px;
- }
- .nestedPost .innerNested {
- width: 100%;
- max-width: 100%;
- display: block;
- font-size: 0.95em;
- }
- .nestedPost .divMessage {
- margin: 0;
- padding: 0;
- white-space: normal;
- display: block;
- }
- .nestedPost .postInfo {
- margin-bottom: 5px;
- font-size: 0.9em;
- display: block;
- }
- .nestedPost img, .nestedPost .imgLink img {
- max-width: 100%;
- max-height: 200px;
- height: auto;
- display: block;
- }
- .nestedPost .imgLink .imgExpanded {
- max-width: 100%;
- max-height: 300px;
- width: auto;
- height: auto;
- }
- .nestedPost a[data-filemime] {
- pointer-events: auto;
- display: inline-block;
- }
- .my-yous__label.unseen {
- background: var(--link-hover-color);
- color: white;
- }
- .my-yous__yous .unseen {
- font-weight: 900;
- color: var(--link-hover-color);
- }
- a[data-filemime^="video/"]:hover, a[data-filemime^="image/"]:hover, a[data-filemime^="audio/"]:hover {
- position: relative;
- }
- a[data-filemime]:focus {
- outline: 2px solid var(--link-hover-color);
- }
- .audio-preview-indicator {
- position: absolute;
- background: rgba(0, 0, 0, 0.7);
- color: white;
- padding: 5px;
- font-size: 12px;
- border-radius: 3px;
- z-index: 1000;
- }
- `;
- try {
- document.head.appendChild(style);
- console.log('[Fullchan X] Styles appended to head.');
- } catch (error) {
- console.error('[Fullchan X] Failed to append styles:', error);
- }
Add Comment
Please, Sign In to add comment