Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // ==UserScript==
- // @name Fullchan‑X (v4.0)
- // @namespace violentmonkey‑fullchan‑x
- // @match https://8chan.moe/*/res/*.html*
- // @match https://8chan.se/*/res/*.html*
- // @version 4.0‑2025‑04‑19
- // @description Recursive inlined quotes, single posts-by-ID window, (You)s window, media previews, preloading
- // @author vfyxe → grok → ChatGPT
- // @grant none
- // ==/UserScript==
- const STYLE_PANEL_BASE = {
- position: 'fixed',
- background: 'var(--contrast-color)',
- color: 'var(--link-color)',
- border: '1px solid var(--navbar-text-color)',
- padding: '6px',
- minWidth: '160px',
- maxWidth: '90vw',
- zIndex: 9999,
- transition: 'width 0.2s ease, height 0.2s ease',
- font: '14px/1 sans-serif'
- };
- const STYLE_SPINNER = {
- position: 'absolute',
- top: '0',
- left: '0',
- zIndex: 10000,
- fontSize: '14px',
- color: 'white',
- background: 'rgba(0,0,0,0.6)',
- padding: '2px 6px',
- borderRadius: '4px',
- pointerEvents: 'none'
- };
- const STYLE_HOVER_MEDIA = {
- position: 'fixed',
- left: '0px',
- top: '0px',
- maxWidth: '90vw',
- maxHeight: '90vh',
- zIndex: 10000,
- pointerEvents: 'none'
- };
- // ✅ Only initialize if we're on a valid thread page
- if (!document.querySelector('#divThreads')) return;
- // ✅ Utility query selectors for brevity
- const QS = (s, r = document) => r.querySelector(s);
- const QSA = (s, r = document) => [...r.querySelectorAll(s)];
- // ✅ Normalizes post IDs (6-digit hex string)
- const idNorm = s => (s.match(/^[0-9a-f]{6}/i) || [''])[0].toLowerCase();
- // ✅ Site-specific constants
- const HOST = location.hostname;
- const OK_MIME = /^(video\/(mp4|webm)|image\/(png|jpe?g|gif)|audio\/mpeg)$/i;
- const OK_EXT = /\.(mp4|webm|png|jpe?g|gif|mp3)$/i;
- const PANEL_SEL = '#fcx-yous, [id^="fcx-ids-"]';
- // ✅ Global LRU-style cache for preloaded media
- const preloadCache = new Map();
- let fcxWebSocket;
- // ✅ Patch the native WebSocket to track its state and log errors
- (() => {
- const NativeWS = window.WebSocket;
- window.WebSocket = class extends NativeWS {
- constructor(...args) {
- super(...args);
- fcxWebSocket = this;
- console.log('[Fullchan-X] WebSocket opened:', args[0]);
- this._fcxUrl = args[0];
- this.addEventListener('close', () => {
- console.warn('[Fullchan-X] WebSocket closed');
- });
- this.addEventListener('error', err => {
- console.warn('[Fullchan-X] WebSocket error', err);
- });
- }
- };
- })();
- function addToCache(key, value) {
- const maxSize = document.querySelector('fullchan-x')?.settings?.mediaCacheSize ?? 50;
- if (preloadCache.size >= maxSize) {
- const firstKey = preloadCache.keys().next().value;
- const old = preloadCache.get(firstKey);
- old?.remove?.(); // ✅ Good: removes lingering DOM nodes if applicable
- preloadCache.delete(firstKey);
- }
- preloadCache.set(key, value);
- }
- function drag(el, grip = el) {
- let sx, sy, ox, oy, moved = false;
- grip.style.cursor = 'move';
- grip.onmousedown = e => {
- sx = e.clientX; sy = e.clientY;
- ({ left: ox, top: oy } = el.getBoundingClientRect());
- const move = ev => {
- const dx = ev.clientX - sx, dy = ev.clientY - sy;
- if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
- el.style.left = ox + dx + 'px';
- el.style.top = oy + dy + 'px';
- };
- const up = () => {
- window.removeEventListener('mousemove', move);
- if (!moved) grip.dispatchEvent(new CustomEvent('tap'));
- moved = false;
- };
- window.addEventListener('mousemove', move);
- window.addEventListener('mouseup', up, { once: true });
- };
- }
- function fit(box) {
- const wrap = box.querySelector('.fcx-id-posts');
- if (!wrap) return;
- // ✅ Temporarily override flex for measurement
- wrap.style.display = 'inline-block';
- wrap.style.flexDirection = 'column';
- wrap.style.alignItems = 'start';
- wrap.style.maxWidth = 'none';
- requestAnimationFrame(() => {
- const width = Math.min(wrap.offsetWidth + 32, window.innerWidth * 0.9);
- const height = wrap.scrollHeight + 64;
- box.style.width = width + 'px';
- box.style.height = height + 'px';
- // ✅ Restore original layout
- wrap.style.display = 'flex';
- wrap.style.maxWidth = '';
- });
- }
- let isHoveringPreview = false; // Place near top of your script
- class FullchanX extends HTMLElement {
- quoteMap = new WeakMap();
- indexIds() {
- QSA('.opCell, .postCell, .post').forEach(p => {
- const l = p.querySelector('.labelId');
- if (!l) return;
- const id = idNorm(l.textContent);
- if (!this.idMap.has(id)) this.idMap.set(id, []);
- this.idMap.get(id).push(p);
- });
- }
- connectedCallback() {
- // ✅ Cache these so we don't repeatedly query for them
- this.thread = QS('.divPosts');
- this.qBox = QS('#quick-reply');
- this.qBody = QS('#qrbody');
- // ✅ Thread ID (used for "seen" (You)s tracking)
- const op = QS('.opCell');
- this.tid = op?.id || 'thread';
- this.seenKey = `${this.tid}-seen-yous`;
- // Track the most recently observed post in the DOM
- this.lastPostEl = null;
- // Initialize last known post ID from the last visible post
- const allPosts = QSA('.postCell, .opCell, .post', this.thread);
- const last = allPosts[allPosts.length - 1];
- const id = last?.id?.replace(/^p/, '');
- this.lastKnownPostId = /^\d+$/.test(id) ? parseInt(id) : 0;
- // Internal tracking for refresh throttling
- let lastRefreshCheck = 0;
- let lastRefreshAttempt = 0;
- let lastKnownPostTime = 0;
- // Load settings
- this.loadSettings();
- this.staleAge = this.settings.staleAge ?? 10000; // 10s default
- this.refreshPoll = this.settings.refreshPoll ?? 5000; // 5s default
- // Smart refresh: check thread JSON, compare against last known post, refresh if needed
- const checkThreadFreshness = () => {
- const now = Date.now();
- // WebSocket fallback — if it disconnects, trigger a manual refresh
- if (!fcxWebSocket || fcxWebSocket.readyState !== 1) {
- console.warn('[FCX] WebSocket down, forcing refresh');
- window.thread?.refreshPosts?.(true);
- }
- // Throttle to avoid excessive polling in background tabs
- if (now - lastRefreshAttempt < this.staleAge) return;
- lastRefreshAttempt = now;
- // Extract board and thread ID from URL
- const tid = location.pathname.match(/\/res\/(\d+)\.html/)?.[1];
- const board = location.pathname.split('/')[1];
- if (!tid || !board) return;
- // Fetch JSON thread snapshot
- fetch(`/${board}/res/${tid}.json`)
- .then(r => r.ok ? r.json() : null)
- .then(json => {
- if (!json || !Array.isArray(json.posts)) return;
- const posts = json.posts;
- const newest = posts[posts.length - 1];
- if (!newest?.no || !newest?.time) return;
- const newestId = parseInt(newest.no);
- const newestTime = newest.time * 1000;
- // Only refresh if post is newer than last seen
- if (newestId > (this.lastKnownPostId || 0) || newestTime > lastKnownPostTime) {
- console.log(`[Fullchan-X] New post detected: ${newestId}`);
- this.lastKnownPostId = newestId;
- lastKnownPostTime = newestTime;
- // Use site's native refresh if available
- if (window.thread?.refreshPosts) {
- window.thread.refreshPosts(true);
- } else {
- console.warn('[FCX] thread.refreshPosts() not available');
- }
- } else {
- console.log(`[Fullchan-X] No new posts (last seen: ${this.lastKnownPostId}, newest: ${newestId})`);
- }
- });
- };
- // Persistent refresh loop using setTimeout (not setInterval)
- const runRefreshLoop = () => {
- checkThreadFreshness();
- setTimeout(runRefreshLoop, this.refreshPoll);
- };
- runRefreshLoop();
- // Refresh immediately on tab re-focus
- document.addEventListener('visibilitychange', () => {
- if (!document.hidden) checkThreadFreshness();
- });
- // Thread ID, tracked for seen (You)s and local storage
- this.seen = JSON.parse(localStorage.getItem(this.seenKey) || '[]');
- // Map of ID → post list
- this.idMap = new Map();
- requestIdleCallback(() => {
- this.indexIds();
- this.observeIds();
- this.markCrossThreadQuotes();
- this.refreshYous();
- }, { timeout: 500 });
- // Add draggable settings icon
- this.createSettingsIcon();
- // Create and initialize the (You)s panel
- this.youBox = this.panel('fcx-yous', 'My (You)s', { right: '1rem', top: '3rem' });
- window.fcxInstance = this;
- // Auto-close reply after successful post
- if (this.settings.closeReplyBox) {
- const qr = this.qBox;
- const form = qr?.querySelector('form');
- const container = this.thread;
- const body = this.qBody;
- form?.addEventListener('submit', () => {
- const before = QSA('.postCell, .post', container).length;
- const obs = new MutationObserver(() => {
- const after = QSA('.postCell, .post', container).length;
- if (after > before) {
- obs.disconnect();
- // ✅ Wait briefly to ensure post is stable in DOM
- setTimeout(() => {
- console.log('[FCX] Post detected, closing reply box');
- if (qr) qr.style.display = 'none';
- if (body) body.value = ''; // ✅ Clear text
- }, 300); // small delay to avoid race with DOM repaint
- }
- });
- obs.observe(container, { childList: true });
- });
- }
- this.setupMediaPreloader();
- // Global event bindings (e.g. quote clicks, media previews)
- this.bindGlobal();
- // Auto-pause audio/video when tab loses focus
- document.addEventListener('visibilitychange', () => {
- if (document.hidden) {
- QSA('audio, video').forEach(el => {
- try { el.pause(); } catch {}
- });
- }
- });
- // Throttled DOM observer update scheduler
- let throttleTimer = null;
- const scheduleUpdate = () => {
- if (throttleTimer) cancelIdleCallback(throttleTimer);
- throttleTimer = requestIdleCallback(() => this.refreshYous(), { timeout: 200 });
- };
- }
- disconnectedCallback() {
- // Cleanly disconnect the IntersectionObserver when the element is removed from DOM
- if (this.io) this.io.disconnect?.();
- }
- observeIds() {
- const container = this.thread;
- if (!container) return;
- const handleNewPost = node => {
- if (!(node instanceof HTMLElement)) return;
- const postElement = node.matches('.opCell, .postCell, .post')
- ? node
- : node.querySelector('.opCell, .postCell, .post');
- if (!postElement) return;
- this.lastPostEl = postElement;
- const label = postElement.querySelector('.labelId');
- if (!label) return;
- const id = idNorm(label.textContent);
- if (!this.idMap.has(id)) {
- this.idMap.set(id, []);
- }
- this.idMap.get(id).push(postElement);
- this.markCrossThreadQuotes(postElement);
- };
- let mutationDebounce = null;
- const pendingNodes = new Set();
- const flushMutations = () => {
- for (const node of pendingNodes) {
- handleNewPost(node);
- }
- pendingNodes.clear();
- };
- new MutationObserver(mutations => {
- mutations.forEach(m => {
- m.addedNodes.forEach(n => pendingNodes.add(n));
- });
- if (mutationDebounce) clearTimeout(mutationDebounce);
- mutationDebounce = setTimeout(flushMutations, 100);
- }).observe(container, {
- childList: true,
- subtree: false
- });
- }
- panel(id, title, pos) {
- // Create a floating panel for IDs or (You)s
- const box = Object.assign(document.createElement('div'), { id });
- // Header includes title and a nav slot
- box.innerHTML = `
- <div class="hdr" style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
- <span class="hdrText">${title}</span>
- <div class="fcx-nav" style="display: flex; gap: 4px;"></div>
- </div>
- <div class="lst"></div>
- `;
- // Main box styling
- Object.assign(box.style, { ...STYLE_PANEL_BASE, ...pos });
- // Cache common sub-elements
- const hdr = box.querySelector('.hdr'),
- text = box.querySelector('.hdrText'),
- lst = box.querySelector('.lst');
- // Style the post list content container
- Object.assign(lst.style, {
- lineHeight: '1.5',
- display: 'block',
- minWidth: '100%',
- whiteSpace: 'normal',
- overflowX: 'hidden',
- overflowY: 'auto'
- });
- // Resize box on load to match content
- requestAnimationFrame(() => box.dataset.baseWidth = box.offsetWidth);
- // Watch for dynamic content and auto-resize as needed
- new MutationObserver(() => fit(box)).observe(lst, { childList: true });
- // Attach to document
- document.body.appendChild(box);
- // Make draggable via header
- drag(box, hdr);
- // Header click behavior: remove box (for ID views) or toggle display (for (You)s)
- if (id.startsWith('fcx-ids-')) {
- text.onclick = () => box.remove();
- } else if (id === 'fcx-yous') {
- text.onclick = () => this.toggleYou(box);
- }
- return box;
- }
- toggleYou(box) {
- // Toggle the visibility of the (You)s list panel
- const lst = box.querySelector('.lst');
- if (lst.style.display === 'none') {
- lst.style.display = 'block';
- fit(box); // Resize to content
- } else {
- lst.style.display = 'none';
- box.style.width = box.style.height = 'auto'; // Collapse when hidden
- }
- }
- pid(post) {
- // Extract post number (ID) from <a> link or fallback to post.id
- const a = QSA('a', post).find(x => /^\d+$/.test(x.textContent.trim()));
- return a ? a.textContent.trim() : (post.id || '').replace(/^p/, '');
- }
- jump(id) {
- // Scroll to a post by ID and briefly outline it
- const p = document.getElementById(id);
- if (!p) return;
- p.scrollIntoView({ behavior: 'smooth', block: 'center' });
- p.style.outline = '2px solid var(--link-color)';
- // Clear outline after 1.5s
- setTimeout(() => {
- p.style.outline = '';
- }, 1500);
- }
- refreshYous() {
- const lst = this.youBox.querySelector('.lst');
- const seenIds = new Set(this.seen);
- const existing = new Set([...lst.querySelectorAll('a')].map(a => a.textContent));
- const frag = document.createDocumentFragment();
- QSA('.quoteLink.you, .quotelink.you', this.thread).forEach(q => {
- const post = q.closest('.postCell, .post');
- if (!post) return;
- const id = post.id;
- const ref = '>>' + id;
- if (!existing.has(ref)) {
- const a = document.createElement('a');
- a.textContent = ref;
- a.href = '#' + id;
- a.style.display = 'block';
- a.style.fontWeight = seenIds.has(id) ? 'normal' : '700';
- a.onclick = e => {
- e.preventDefault();
- this.jump(id);
- };
- frag.appendChild(a);
- }
- });
- lst.appendChild(frag);
- if (!this.io) {
- this.io = new IntersectionObserver(entries => {
- let updated = false;
- for (const ent of entries) {
- if (ent.isIntersecting) {
- const id = ent.target.id;
- if (!seenIds.has(id)) {
- this.seen.push(id);
- updated = true;
- if (this.seen.length > 500) {
- this.seen = this.seen.slice(-500);
- }
- this.io.unobserve(ent.target);
- }
- }
- }
- if (updated) {
- localStorage.setItem(this.seenKey, JSON.stringify(this.seen));
- // Only refresh DOM once after batch updates
- requestIdleCallback(() => this.refreshYous());
- }
- }, { threshold: 0.15 });
- }
- const unseen = QSA('.postCell, .post', this.thread)
- .filter(p => !seenIds.has(p.id))
- .slice(0, 200);
- unseen.forEach(p => this.io.observe(p));
- }
- openIdBox(raw) {
- const id = idNorm(raw); // Normalize input to a 6-char hex ID
- const boxId = `fcx-ids-${id}`;
- const prevOpen = QS('[id^="fcx-ids-"]');
- // If a different ID box is open, remove it and clear tracked posts
- if (prevOpen && prevOpen.id !== boxId) {
- prevOpen.remove();
- this.idMap.clear();
- } else if (prevOpen) {
- // Clicking again on the same ID closes it
- prevOpen.remove();
- this.idMap.clear();
- return;
- }
- // Build map of posts with the given ID
- this.idMap.clear();
- this.idMap.set(id, []);
- QSA('.opCell, .postCell, .post').forEach(p => {
- const label = p.querySelector('.labelId');
- if (!label) return;
- if (idNorm(label.textContent) === id) {
- this.idMap.get(id).push(p);
- }
- });
- const posts = this.idMap.get(id) || [];
- // Create floating panel to display ID-matched posts
- const box = this.panel(boxId, `Posts by ID: ${id}`, { right: '1rem', top: '12rem' });
- box.classList.add('fcx-idview');
- const hdr = box.querySelector('.hdr');
- const lst = box.querySelector('.lst');
- // Add ◀ ▶ nav buttons
- const nav = document.createElement('div');
- nav.className = 'fcx-nav';
- nav.innerHTML = `
- <button style="width:2em;">◀</button>
- <button style="width:2em;">▶</button>
- `;
- hdr.appendChild(nav);
- const [prev, next] = nav.querySelectorAll('button');
- // Set up container for cloned post views
- const wrap = document.createElement('div');
- Object.assign(wrap.style, {
- display: 'flex',
- flexDirection: 'column',
- gap: '8px',
- alignItems: 'stretch',
- });
- wrap.className = 'fcx-id-posts';
- lst.appendChild(wrap);
- // Lazy-load post content for performance
- let clones = new Array(posts.length).fill(null);
- const loadPost = i => {
- const post = posts[i];
- if (!post || clones[i]) return;
- const num = this.pid(post);
- const body = post.querySelector('.innerOP, .innerPost, .postMessage');
- if (!num || !body) return;
- const container = document.createElement('div');
- Object.assign(container.style, {
- border: '1px solid var(--navbar-text-color)',
- padding: '6px',
- borderRadius: '4px',
- backgroundColor: 'rgba(255,255,255,0.02)',
- boxSizing: 'border-box',
- overflow: 'auto',
- overflowWrap: 'break-word',
- maxHeight: '60vh'
- });
- const content = body.cloneNode(true);
- container.appendChild(content);
- // Make inlined quotes inside cloned post clickable
- QSA('a.quoteLink, a.quotelink', container).forEach(a => {
- if (!a.textContent.startsWith('>>')) return;
- a.onclick = e => {
- e.preventDefault();
- const match = a.href.match(/#q?(\d+)/);
- if (match) this.jump(match[1]);
- };
- });
- clones[i] = container;
- };
- let idx = 0;
- const show = i => {
- idx = (i + posts.length) % posts.length;
- const nextIdx = (idx + 1) % posts.length;
- wrap.style.display = 'none';
- wrap.innerHTML = '';
- loadPost(idx);
- loadPost(nextIdx); // Preload next
- wrap.appendChild(clones[idx]);
- if (clones[nextIdx] && posts.length > 1) {
- wrap.appendChild(clones[nextIdx]);
- }
- requestAnimationFrame(() => {
- wrap.style.display = 'flex';
- requestAnimationFrame(() => fit(box));
- });
- };
- // Keyboard & nav button controls
- prev.onclick = () => show(idx - 2);
- next.onclick = () => show(idx + 2);
- box.tabIndex = 0;
- box.onkeydown = e => {
- if (e.key === 'ArrowLeft') { e.preventDefault(); show(idx - 2); }
- if (e.key === 'ArrowRight') { e.preventDefault(); show(idx + 2); }
- };
- drag(box, hdr);
- show(0); // Start with first matched post
- box.focus();
- }
- toggleQuote(pid, link, small = false) {
- // Remove previously inlined quote if already open
- if (this.quoteMap.has(link)) {
- const existing = this.quoteMap.get(link);
- existing.remove();
- this.quoteMap.delete(link);
- return;
- }
- // Find quoted source post
- const src = document.getElementById(pid) || document.getElementById('p' + pid);
- if (!src) return;
- const body = src.querySelector('.innerOP, .innerPost, .postMessage');
- if (!body) return;
- // Determine quote nesting level
- const inPanel = !!link.closest(PANEL_SEL);
- const depth = inPanel
- ? +(link.dataset.depth || 0)
- : +(link.closest('.nestedPost')?.dataset.depth || 0);
- // Create the wrapper that indents and contains the quote
- const wrap = document.createElement('div');
- wrap.dataset.depth = depth + 1;
- wrap.className = 'nestedPost';
- Object.assign(wrap.style, {
- border: '1px solid var(--navbar-text-color)',
- margin: `.3em 0 .3em ${(depth + 1) * 20}px`,
- padding: '4px',
- width: '100%',
- boxSizing: 'border-box',
- overflow: 'visible',
- display: 'block',
- fontSize: small ? '90%' : ''
- });
- // Inner block matches site's post content layout
- const inner = document.createElement('div');
- Object.assign(inner.style, {
- display: 'inline-block',
- maxWidth: '100%',
- border: '1px solid var(--navbar-text-color)',
- borderRadius: '4px',
- padding: '4px',
- background: 'var(--contrast-color)',
- boxSizing: 'border-box'
- });
- inner.appendChild(body.cloneNode(true));
- wrap.appendChild(inner);
- // Save for toggle tracking
- this.quoteMap.set(link, wrap);
- link.dataset.depth = depth + 1;
- // Insert after the post or quote that invoked it
- const parentPost = link.closest('.nestedPost') || link.closest('.postCell, .post');
- if (parentPost) {
- parentPost.appendChild(wrap);
- } else {
- link.after(wrap);
- }
- // Resize panel if inside a floating viewer
- if (inPanel) fit(link.closest(PANEL_SEL));
- }
- createSettingsIcon() {
- const icon = document.createElement('div');
- icon.id = 'fcx-settings-icon';
- icon.innerHTML = '⚙️';
- // Visual and positioning styles
- Object.assign(icon.style, {
- position: 'fixed',
- bottom: '1rem',
- right: '1rem',
- fontSize: '20px',
- cursor: 'pointer',
- zIndex: 10000
- });
- // Open/close the settings panel on click
- icon.onclick = () => this.toggleSettingsPanel();
- document.body.appendChild(icon);
- }
- toggleSettingsPanel() {
- const existing = document.getElementById('fcx-settings-panel');
- if (existing) {
- existing.remove(); // Close if already open
- return;
- }
- this.createSettingsPanel(); // Open if closed
- }
- createSettingsPanel() {
- const panel = document.createElement('div');
- panel.id = 'fcx-settings-panel';
- Object.assign(panel.style, {
- position: 'fixed',
- bottom: '3rem',
- right: '1rem',
- background: 'var(--contrast-color)',
- color: 'var(--link-color)',
- border: '1px solid var(--navbar-text-color)',
- padding: '10px',
- borderRadius: '6px',
- zIndex: 10000,
- font: '14px sans-serif',
- minWidth: '220px'
- });
- panel.innerHTML = `
- <label style="display:block; margin-bottom:8px;">
- <input type="checkbox" id="fcx-toggle-yous">
- Show My (You)s
- </label>
- <label style="display:block; margin-bottom:8px;">
- <input type="checkbox" id="fcx-close-reply-box">
- Auto-close Quick Reply after posting
- </label>
- <label style="display:block; margin-bottom:8px;">
- Media Volume:
- <input type="range" id="fcx-volume-slider" min="0" max="100" style="width: 100%;">
- </label>
- <label style="display:block; margin-bottom:8px;">
- <input type="checkbox" id="fcx-hide-stubs">
- Hide Filtered Stubs
- </label>
- <label style="display:block; margin-bottom:8px;">
- <input type="checkbox" id="fcx-anonymize">
- Anonymize (hide IDs)
- </label>
- <label style="display:block; margin-bottom:8px;">
- <input type="checkbox" id="fcx-preload-images">
- Preload Images
- </label>
- <label style="display:block; margin-bottom:8px;">
- Max Posts to Preload:
- <input type="number" id="fcx-preload-posts" min="1" max="50" value="5" style="width: 100%;">
- </label>
- <label style="display:block; margin-bottom:8px;">
- Refresh Interval (ms) (Requires Reload):
- <input type="number" id="fcx-refresh-poll" min="1000" max="60000" value="5000" style="width: 100%;">
- </label>
- <label style="display:block; margin-bottom:8px;">
- Stale Threshold (ms) (Requires Reload):
- <input type="number" id="fcx-stale-age" min="2000" max="120000" value="10000" style="width: 100%;">
- </label>
- <label style="display:block; margin-bottom:8px;">
- Max Media Cache Size:
- <input type="number" id="fcx-cache-size" min="5" max="200" value="50" style="width: 100%;">
- </label>
- <button id="fcx-stop-previews" style="margin-top:10px; width: 100%;">⏹️ Stop All Previews</button>
- `;
- document.body.appendChild(panel);
- // Bind values from settings
- QS('#fcx-toggle-yous').checked = this.settings.showYous;
- QS('#fcx-volume-slider').value = Math.round(this.settings.volume * 100);
- QS('#fcx-hide-stubs').checked = this.settings.hideStubs;
- QS('#fcx-anonymize').checked = this.settings.anonymize;
- QS('#fcx-preload-images').checked = this.settings.preloadImages;
- QS('#fcx-preload-posts').value = this.settings.preloadPosts;
- QS('#fcx-cache-size').value = this.settings.mediaCacheSize;
- QS('#fcx-close-reply-box').checked = this.settings.closeReplyBox;
- QS('#fcx-close-reply-box').onchange = e => {
- this.settings.closeReplyBox = e.target.checked;
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- };
- // Bind actions to inputs
- QS('#fcx-stop-previews').onclick = () => this.stopAllPreviews();
- QS('#fcx-cache-size').oninput = e => {
- const val = Math.max(5, Math.min(200, parseInt(e.target.value) || 50));
- this.settings.mediaCacheSize = val;
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- };
- QS('#fcx-preload-images').onchange = e => {
- this.settings.preloadImages = e.target.checked;
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- };
- QS('#fcx-refresh-poll').value = this.settings.refreshPoll;
- QS('#fcx-stale-age').value = this.settings.staleAge;
- QS('#fcx-refresh-poll').oninput = e => {
- this.settings.refreshPoll = Math.max(1000, parseInt(e.target.value) || 5000);
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- this.refreshPoll = this.settings.refreshPoll;
- };
- QS('#fcx-stale-age').oninput = e => {
- this.settings.staleAge = Math.max(2000, parseInt(e.target.value) || 10000);
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- this.staleAge = this.settings.staleAge;
- };
- QS('#fcx-preload-posts').oninput = e => {
- this.settings.preloadPosts = Math.max(1, Math.min(50, parseInt(e.target.value) || 5));
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- };
- QS('#fcx-anonymize').onchange = e => {
- this.settings.anonymize = e.target.checked;
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- document.body.classList.toggle('fcx-anonymize', this.settings.anonymize);
- };
- QS('#fcx-toggle-yous').onchange = e => {
- this.settings.showYous = e.target.checked;
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- this.youBox.style.display = this.settings.showYous ? 'block' : 'none';
- fit(this.youBox);
- };
- QS('#fcx-volume-slider').oninput = e => {
- this.settings.volume = +e.target.value / 100;
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- };
- QS('#fcx-hide-stubs').onchange = e => {
- this.settings.hideStubs = e.target.checked;
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- document.body.classList.toggle('fcx-hide-stubs', this.settings.hideStubs);
- };
- }
- loadSettings() {
- const saved = JSON.parse(localStorage.getItem('fcx-settings') || '{}');
- this.settings = {
- showYous: saved.showYous ?? true,
- volume: saved.volume ?? 0.5,
- hideStubs: saved.hideStubs ?? false,
- anonymize: saved.anonymize ?? false,
- preloadImages: saved.preloadImages ?? true,
- preloadPosts: saved.preloadPosts ?? 5,
- mediaCacheSize: saved.mediaCacheSize ?? 50,
- closeReplyBox: saved.closeReplyBox ?? false,
- staleAge: saved.staleAge ?? 10000,
- refreshPoll: saved.refreshPoll ?? 5000
- };
- // Immediately apply relevant settings
- if (!this.settings.showYous) {
- this.youBox.style.display = 'none';
- }
- if (this.settings.hideStubs) {
- document.body.classList.add('fcx-hide-stubs');
- }
- if (this.settings.anonymize) {
- document.body.classList.add('fcx-anonymize');
- }
- }
- stopAllPreviews() {
- // Stop and remove all audio/video elements (from hover previews)
- QSA('audio, video').forEach(el => {
- try {
- el.pause();
- el.currentTime = 0;
- } catch (err) {}
- el.remove();
- });
- // Remove image previews (usually floating ones triggered by hover)
- QSA('img').forEach(el => {
- const style = getComputedStyle(el);
- if (style.position === 'fixed' || el.dataset.fcxHover || el.style.pointerEvents === 'none') {
- el.remove();
- }
- });
- // Remove any loading spinners from pending media loads
- QSA('.fcx-spinner').forEach(spin => spin.remove());
- // Clear preview-related tracking from anchor elements
- QSA('a[data-filemime]').forEach(a => {
- delete a._prev;
- delete a._preloaded;
- });
- }
- bindGlobal() {
- // 🧠 Fallback for quote tooltips if native one fails to appear
- this.thread.addEventListener('pointerover', e => {
- const qLink = e.target.closest('a.quoteLink, a.quotelink');
- if (!qLink) return;
- const match = qLink.href.match(/#q?(\d+)/);
- if (!match) return;
- const pid = match[1];
- const existing = document.getElementById(pid) || document.getElementById('p' + pid);
- if (!existing) return;
- setTimeout(() => {
- const tooltip = document.querySelector('body > div.quoteTooltip');
- const isVisible = tooltip && tooltip.offsetParent !== null;
- if (isVisible || qLink._fcxHoverQuote) return;
- const body = existing.querySelector('.innerPost, .postMessage, .divMessage');
- if (!body) return;
- const preview = document.createElement('div');
- preview.className = 'fcx-hover-quote';
- Object.assign(preview.style, {
- position: 'absolute',
- zIndex: '10000',
- pointerEvents: 'none',
- maxWidth: '60vw',
- maxHeight: '50vh',
- overflow: 'auto',
- background: 'var(--contrast-color)',
- border: '1px solid var(--navbar-text-color)',
- padding: '6px',
- borderRadius: '6px',
- font: '14px sans-serif',
- boxShadow: '0 2px 8px rgba(0,0,0,0.3)'
- });
- preview.appendChild(body.cloneNode(true));
- document.body.appendChild(preview);
- qLink._fcxHoverQuote = preview;
- const rect = qLink.getBoundingClientRect();
- preview.style.left = `${rect.right + 10}px`;
- preview.style.top = `${rect.top + window.scrollY}px`;
- const cleanup = () => {
- preview.remove();
- delete qLink._fcxHoverQuote;
- window.removeEventListener('mousemove', checkMouse);
- };
- const checkMouse = e => {
- const overLink = qLink.matches(':hover');
- const overPreview = preview.matches(':hover');
- if (!overLink && !overPreview) cleanup();
- };
- window.addEventListener('mousemove', checkMouse);
- }, 100);
- }, { passive: true });
- // 📌 Quote link click (expand inline or jump to post)
- this.thread.addEventListener('click', e => {
- const link = e.target.closest('a.quoteLink, a.quotelink');
- if (!link || !link.textContent.startsWith('>>')) return;
- const match = link.href.match(/#q?(\d+)/);
- if (!match) return;
- e.preventDefault();
- const quoteId = match[1];
- const inPanel = link.closest('[id^="fcx-ids-"]');
- if (inPanel) {
- this.jump(quoteId);
- } else {
- this.toggleQuote(quoteId, link);
- }
- }, true);
- // 🧠 Post number and ID click handler
- document.addEventListener('click', e => {
- const pn = e.target.closest('a.postNum, a.post_no, a.no, a.linkQuote');
- if (pn) {
- e.preventDefault();
- e.stopPropagation();
- e.stopImmediatePropagation();
- const id = pn.hash.slice(1).replace(/^q/, '');
- if (pn.closest('[id^="fcx-ids-"]')) this.jump(id);
- else {
- this.qBox.style.display = 'block';
- this.qBody.value += '>>' + id + '\n';
- this.qBody.focus();
- }
- return;
- }
- const idEl = e.target.closest('.labelId');
- if (idEl) {
- e.preventDefault();
- this.openIdBox(idEl.textContent);
- }
- }, true);
- // 🖼️ Media preloading on hover (image/video)
- this.thread.addEventListener('pointerover', e => {
- const a = e.target.closest('a[data-filemime]');
- if (!a || a._preloaded || a._prev) return;
- const url = a.href;
- const mime = a.dataset.filemime?.toLowerCase() || '';
- if (mime.startsWith('image/')) {
- const img = new Image();
- img.src = url;
- a._preloadImage = img;
- addToCache(url, img);
- img.decode?.().catch(() => {});
- } else {
- fetch(url, { mode: 'no-cors' }).catch(() => {});
- }
- a._preloaded = true;
- }, { passive: true });
- // 🎞️ Media preview logic (on hover)
- this.thread.addEventListener('pointerenter', async e => {
- const anchor = e.target.closest('a[data-filemime]');
- if (!anchor || anchor.dataset._fcxHoverActive === 'true') return;
- anchor.dataset._fcxHoverActive = 'true';
- isHoveringPreview = true;
- this._activePreviewAnchor?.dispatchEvent(new Event('mouseleave'));
- this._activePreviewAnchor = anchor;
- const originalUrl = anchor.href;
- const mime = anchor.dataset.filemime?.toLowerCase() || '';
- if (!OK_MIME.test(mime) && !OK_EXT.test(originalUrl)) return;
- if (originalUrl.includes('catbox.moe') || originalUrl.includes('z.zz.fo')) return;
- const isVideo = mime.startsWith('video/');
- const isAudio = mime.startsWith('audio/');
- const isImg = mime.startsWith('image/');
- let url = originalUrl;
- if ((isVideo || isAudio) && !url.includes('?')) {
- url += `?fcxcb=${Date.now()}`;
- }
- const spinner = document.createElement('div');
- Object.assign(spinner.style, STYLE_SPINNER);
- spinner.textContent = '⏳ Loading ⏳';
- spinner.className = 'fcx-spinner';
- let el;
- let loadTimeout;
- let hoverActive = true;
- const cancelHover = () => {
- hoverActive = false;
- isHoveringPreview = false;
- el?.pause?.();
- el?.remove();
- clearTimeout(loadTimeout);
- anchor.contains(spinner) && spinner.remove();
- delete anchor._prev;
- delete anchor.dataset._fcxHoverActive;
- };
- anchor.addEventListener('mouseleave', cancelHover, { once: true });
- anchor.addEventListener('pointercancel', cancelHover, { once: true });
- if (isImg) {
- el = anchor._preloadImage || new Image();
- el.src = url;
- el.dataset.fcxHover = 'true';
- Object.assign(el.style, STYLE_HOVER_MEDIA);
- el.onload = () => {
- if (!hoverActive) return;
- anchor.contains(spinner) && spinner.remove();
- };
- el.onerror = cancelHover;
- if (!document.body.contains(el)) {
- document.body.appendChild(el);
- }
- loadTimeout = setTimeout(() => {
- if (hoverActive && anchor.matches(':hover')) {
- anchor.appendChild(spinner);
- }
- }, 150);
- return;
- }
- const cached = anchor._preloadMedia;
- if (cached) {
- el = cached;
- el.currentTime = 0;
- el.style.display = 'block';
- } else {
- el = document.createElement(isVideo ? 'video' : 'audio');
- el.src = url;
- el.preload = 'auto';
- el.muted = true;
- document.body.appendChild(el);
- anchor._preloadMedia = el;
- addToCache(url, el);
- }
- Object.assign(el, {
- loop: true,
- volume: this.settings?.volume ?? 0.5,
- muted: (this.settings?.volume ?? 0) === 0,
- });
- el.dataset.fcxHover = 'true';
- Object.assign(el.style, {
- position: 'fixed',
- left: '0px',
- top: '0px',
- maxWidth: '90vw',
- maxHeight: '90vh',
- zIndex: 10000,
- pointerEvents: 'none'
- });
- document.body.appendChild(el);
- let attempt = 0;
- const maxRetries = 3;
- const tryLoad = () => {
- if (!hoverActive) return;
- attempt++;
- const start = performance.now();
- if (!cached) el.load?.();
- const checkReady = setInterval(() => {
- if (!hoverActive) {
- clearInterval(checkReady);
- el.remove();
- anchor.contains(spinner) && spinner.remove();
- return;
- }
- if (el.readyState >= 2) {
- clearInterval(checkReady);
- clearTimeout(loadTimeout);
- anchor.contains(spinner) && spinner.remove();
- el.play?.().catch(() => {});
- anchor._prev = el;
- return;
- }
- if (performance.now() - start > 2500) {
- clearInterval(checkReady);
- el.remove();
- anchor.contains(spinner) && spinner.remove();
- if (attempt < maxRetries && hoverActive) {
- setTimeout(tryLoad, 300);
- }
- }
- }, 100);
- };
- loadTimeout = setTimeout(() => {
- if (hoverActive && anchor.matches(':hover')) {
- anchor.appendChild(spinner);
- }
- }, 150);
- tryLoad();
- }, { capture: true, passive: true });
- // ❌ Stop previews if user scrolls or touches
- ['scroll', 'wheel', 'touchmove'].forEach(ev => {
- window.addEventListener(ev, () => this.stopHoverPreviews(), { passive: true });
- });
- }
- stopHoverPreviews() {
- // Avoid interrupting an active preview
- if (isHoveringPreview) return;
- // Remove all hover-tagged media
- QSA('img, video, audio').forEach(el => {
- if (el.dataset.fcxHover) {
- el.pause?.();
- el.remove();
- }
- });
- // Redundant catch-all for untagged media (extra safety)
- QSA('audio, video').forEach(el => {
- el.pause?.();
- el.remove();
- });
- // Clear anchor link preview tracking
- QSA('a[data-filemime]').forEach(a => {
- if (a._prev) delete a._prev;
- });
- // Remove loading indicators
- QSA('.fcx-spinner').forEach(s => s.remove());
- }
- setupMediaPreloader() {
- const MAX_PRELOAD = this.settings.preloadPosts ?? 5; // Number of posts to preload
- const BUFFER_POSTS = MAX_PRELOAD; // Same size buffer before/after
- const SCROLL_IDLE_DELAY = 200; // Delay before triggering preload
- if (!this.settings.preloadImages) return;
- let lastScrollTop = window.scrollY;
- let lastScrollTime = Date.now();
- let scrollDir = 'down';
- let scrollTimer = null;
- let isScrolling = false;
- // 🔁 Handle actual media preloading
- const preload = (a) => {
- if (a._preloaded || a._prev) return;
- const url = a.href;
- const mime = a.dataset.filemime?.toLowerCase() || '';
- if (mime.startsWith('image/')) {
- const img = new Image();
- img.src = url;
- img.decode?.().catch(() => {});
- } else if (mime.startsWith('video/') || mime.startsWith('audio/')) {
- const media = document.createElement(mime.startsWith('video/') ? 'video' : 'audio');
- media.src = url;
- media.preload = 'auto';
- media.muted = true;
- media.style.display = 'none';
- document.body.appendChild(media);
- media.load();
- a._preloadMedia = media;
- } else {
- fetch(url, { mode: 'no-cors' }).catch(() => {});
- }
- a._preloaded = true;
- };
- // 🧠 Track scroll direction and debounce logic
- const updateScrollState = () => {
- const now = Date.now();
- const top = window.scrollY;
- scrollDir = (top > lastScrollTop) ? 'down' : 'up';
- lastScrollTop = top;
- lastScrollTime = now;
- isScrolling = true;
- if (scrollTimer) clearTimeout(scrollTimer);
- scrollTimer = setTimeout(() => {
- isScrolling = false;
- doSmartPreload();
- }, SCROLL_IDLE_DELAY);
- };
- // 📦 Get posts currently visible in viewport
- const getVisiblePosts = () => {
- const posts = QSA('.postCell, .opCell', this.thread);
- const winTop = window.scrollY;
- const winBottom = winTop + window.innerHeight;
- const result = [];
- for (let i = 0; i < posts.length; i++) {
- const post = posts[i];
- const rect = post.getBoundingClientRect();
- const absTop = rect.top + window.scrollY;
- const absBottom = rect.bottom + window.scrollY;
- if (absBottom > winTop && absTop < winBottom) {
- result.push({ post, index: i });
- }
- }
- return result;
- };
- // 🎯 Smart preload around current visible center post
- const doSmartPreload = () => {
- if (!this.settings.preloadImages) return;
- const posts = QSA('.postCell, .opCell', this.thread);
- const visible = getVisiblePosts();
- if (visible.length === 0) return;
- const centerIndex = visible[Math.floor(visible.length / 2)].index;
- // 👀 Select posts around the current center post
- const buffer = [];
- for (let offset = -BUFFER_POSTS; offset <= BUFFER_POSTS; offset++) {
- const idx = scrollDir === 'up'
- ? centerIndex - offset
- : centerIndex + offset;
- if (posts[idx]) buffer.push(posts[idx]);
- }
- // Combine visible + buffer, remove duplicates
- const postSet = new Set([...visible.map(v => v.post), ...buffer]);
- // Extract media links to preload
- const mediaLinks = [...postSet]
- .flatMap(post => QSA('a[data-filemime]', post).filter(a => !a._preloaded && !a._prev))
- .slice(0, MAX_PRELOAD);
- // Prioritize videos over static images
- mediaLinks.sort((a, b) => {
- const m1 = a.dataset.filemime || '';
- const m2 = b.dataset.filemime || '';
- if (m1.startsWith('video/') && !m2.startsWith('video/')) return -1;
- if (!m1.startsWith('video/') && m2.startsWith('video/')) return 1;
- return 0;
- });
- // Preload with idle priority
- requestIdleCallback(() => {
- mediaLinks.forEach(preload);
- }, { timeout: 100 });
- };
- // 👂 Listeners
- window.addEventListener('scroll', updateScrollState, { passive: true });
- window.addEventListener('resize', doSmartPreload, { passive: true });
- requestIdleCallback(() => doSmartPreload());
- }
- markCrossThreadQuotes(container = document) {
- // Search all quote links within the given container
- QSA('.quoteLink, .quotelink', container).forEach(q => {
- // Skip if already processed
- if (q.dataset.crossThreaded) return;
- // 🧠 Case 1: Explicit format >>12345 (67890)
- const explicitMatch = q.textContent.match(/^>>(\d+)\s+\((\d+)\)$/);
- if (explicitMatch) {
- const [ , postId, threadId ] = explicitMatch;
- // If it's not from the current thread, rewrite the href and tag
- if (threadId !== this.tid) {
- q.href = `/${location.pathname.split('/')[1]}/res/${threadId}.html#${postId}`;
- this._tagAsCrossThread(q);
- }
- return; // No need to proceed further if already matched
- }
- // 🧠 Case 2: Link directly to a post in another thread
- const hrefMatch = q.getAttribute('href')?.match(/\/res\/(\d+)\.html#(\d+)/);
- if (hrefMatch) {
- const [ , threadId, postId ] = hrefMatch;
- if (threadId !== this.tid) {
- this._tagAsCrossThread(q);
- }
- }
- });
- }
- _tagAsCrossThread(q) {
- // 🛑 Prevent duplicate visual tag
- if (q.querySelector('.cross-thread-label')) return;
- // ✍️ Create and style the label
- const label = document.createElement('span');
- label.textContent = '(Cross-Thread)';
- label.className = 'cross-thread-label';
- Object.assign(label.style, {
- fontStyle: 'italic',
- fontSize: '90%',
- color: 'var(--link-color)',
- marginLeft: '4px',
- pointerEvents: 'none' // Prevent accidental clicks
- });
- // 👷 Add label after the quote link
- q.appendChild(label);
- q.dataset.crossThreaded = 'true';
- }
- }
- // Register the custom element so it can be used as <fullchan-x>
- customElements.define('fullchan-x', FullchanX);
- // Utility: Insert <link rel="preconnect"> to improve initial connection time
- const preconnect = url => {
- const link = document.createElement('link');
- link.rel = 'preconnect';
- link.href = url;
- link.crossOrigin = '';
- document.head.appendChild(link);
- };
- // Preconnect to the site's origin for quicker resource loading
- preconnect(location.origin); // Safe: same-origin and dynamically scoped
- // Inject the main component into the page
- document.body.appendChild(document.createElement('fullchan-x'));
- // Inject custom CSS to style optional features and UI panels
- const style = document.createElement('style');
- style.textContent = `
- /* Hide filtered stubs when enabled */
- body.fcx-hide-stubs .postCell:has(.unhideButton.glowOnHover) {
- display: none !important;
- }
- /* Hide user IDs when anonymize mode is enabled */
- body.fcx-anonymize .spanId {
- display: none !important;
- }
- /* Style <input type="number"> fields in settings panel */
- #fcx-settings-panel input[type="number"] {
- background: var(--contrast-color);
- color: var(--link-color);
- border: 1px solid var(--navbar-text-color);
- padding: 4px;
- border-radius: 4px;
- box-sizing: border-box;
- max-width: 80px;
- }
- `;
- document.head.appendChild(style);
Add Comment
Please, Sign In to add comment