Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // ==UserScript==
- // @name Fullchan‑X (v3.15)
- // @namespace violentmonkey‑fullchan‑x
- // @match https://8chan.moe/*/res/*.html*
- // @match https://8chan.se/*/res/*.html*
- // @version 3.15‑2025‑04‑17
- // @description Recursive inlined quotes, single posts-by-ID window, (You)s window, media previews, hide stubs, anonymize (hide IDs), preloading
- // @author vfyxe → grok → ChatGPT
- // @grant none
- // ==/UserScript==
- if (!document.querySelector('#divThreads')) return;
- const QS = (s, r = document) => r.querySelector(s);
- const QSA = (s, r = document) => [...r.querySelectorAll(s)];
- const idNorm = s => (s.match(/^[0-9a-f]{6}/i) || [''])[0].toLowerCase();
- 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-"]';
- 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 force-wrap to shrink to content
- 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 flex layout after measurement
- wrap.style.display = 'flex';
- wrap.style.maxWidth = '';
- });
- }
- 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() {
- this.thread = QS('.divPosts');
- this.qBox = QS('#quick-reply');
- this.qBody = QS('#qrbody');
- this.tid = QS('.opCell')?.id || 'thread';
- this.seenKey = `${this.tid}-seen-yous`;
- this.seen = JSON.parse(localStorage.getItem(this.seenKey) || '[]');
- this.idMap = new Map();
- this.indexIds();
- this.observeIds();
- this.markCrossThreadQuotes();
- this.createSettingsIcon();
- this.youBox = this.panel('fcx-yous', 'My (You)s', { right: '1rem', top: '3rem' });
- this.loadSettings();
- this.setupMediaPreloader();
- this.refreshYous();
- this.bindGlobal();
- document.addEventListener('visibilitychange', () => {
- if (document.hidden) {
- QSA('audio, video').forEach(el => {
- try { el.pause(); } catch {}
- });
- }
- });
- // Efficiently observe new posts with throttling
- let throttleTimer = null;
- const scheduleUpdate = () => {
- if (throttleTimer) cancelIdleCallback(throttleTimer);
- throttleTimer = requestIdleCallback(() => this.refreshYous(), { timeout: 200 });
- };
- new MutationObserver(scheduleUpdate).observe(this.thread, {
- childList: true,
- subtree: false
- });
- }
- disconnectedCallback() {
- if (this.io) this.io.disconnect?.();
- }
- observeIds() {
- const container = QS('.divPosts');
- 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;
- 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);
- // Mark any cross-thread quotes in this new post
- this.markCrossThreadQuotes(postElement);
- };
- new MutationObserver(mutations => {
- for (const mutation of mutations) {
- for (const node of mutation.addedNodes) {
- handleNewPost(node);
- }
- }
- }).observe(container, {
- childList: true,
- subtree: false
- });
- }
- panel(id, title, pos) {
- const box = Object.assign(document.createElement('div'), { id });
- 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>
- `;
- Object.assign(box.style, {
- 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', ...pos
- });
- const hdr = box.querySelector('.hdr'),
- text = box.querySelector('.hdrText'),
- lst = box.querySelector('.lst');
- Object.assign(lst.style, {
- lineHeight: '1.5',
- display: 'block',
- minWidth: '100%',
- whiteSpace: 'normal',
- overflowX: 'hidden',
- overflowY: 'auto'
- });
- requestAnimationFrame(() => box.dataset.baseWidth = box.offsetWidth);
- new MutationObserver(() => fit(box)).observe(lst, { childList: true });
- document.body.appendChild(box);
- drag(box, hdr);
- if (id.startsWith('fcx-ids-')) text.onclick = () => box.remove();
- else if (id === 'fcx-yous') text.onclick = () => this.toggleYou(box);
- return box;
- }
- toggleYou(box) {
- const lst = box.querySelector('.lst');
- if (lst.style.display === 'none') {
- lst.style.display = 'block';
- fit(box);
- } else {
- lst.style.display = 'none';
- box.style.width = box.style.height = 'auto';
- }
- }
- pid(post) {
- const a = QSA('a', post).find(x => /^\d+$/.test(x.textContent.trim()));
- return a ? a.textContent.trim() : (post.id || '').replace(/^p/, '');
- }
- jump(id) {
- const p = document.getElementById(id);
- if (!p) return;
- p.scrollIntoView({ behavior: 'smooth', block: 'center' });
- p.style.outline = '2px solid var(--link-color)';
- 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(ents => ents.forEach(ent => {
- if (ent.isIntersecting) {
- const id = ent.target.id;
- if (!seenIds.has(id)) {
- this.seen.push(id);
- localStorage.setItem(this.seenKey, JSON.stringify(this.seen));
- this.io.unobserve(ent.target);
- this.refreshYous();
- }
- }
- }), { threshold: .15 });
- }
- this.io.disconnect();
- QSA('.postCell, .post', this.thread)
- .filter(p => !seenIds.has(p.id))
- .slice(0, 200)
- .forEach(p => this.io.observe(p));
- }
- openIdBox(raw) {
- const id = idNorm(raw);
- const boxId = `fcx-ids-${id}`;
- const prevOpen = QS('[id^="fcx-ids-"]');
- if (prevOpen && prevOpen.id !== boxId) prevOpen.remove();
- else if (prevOpen) { prevOpen.remove(); return; }
- const posts = (this.idMap.get(id) || []).slice(0, 10);
- const box = this.panel(boxId, `Posts by ID: ${id}`, { right: '1rem', top: '12rem' });
- box.classList.add('fcx-idview'); // mark as ID view panel
- // 🧠 Make header a flex row with title + arrows
- const hdr = box.querySelector('.hdr');
- hdr.style.display = 'flex';
- hdr.style.alignItems = 'center';
- hdr.style.justifyContent = 'space-between';
- hdr.style.gap = '8px';
- // Create title + nav container
- const title = box.querySelector('.hdrText');
- const nav = document.createElement('div');
- nav.className = 'fcx-nav';
- Object.assign(nav.style, {
- display: 'flex',
- gap: '4px'
- });
- nav.innerHTML = `
- <button style="width:2em;">◀</button>
- <button style="width:2em;">▶</button>
- `;
- hdr.appendChild(nav);
- const [prev, next] = nav.querySelectorAll('button');
- const lst = box.querySelector('.lst');
- 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);
- const clones = posts.map(post => {
- const num = this.pid(post);
- const body = post.querySelector('.innerOP, .innerPost, .postMessage');
- if (!num || !body) return null;
- 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);
- 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]);
- }
- };
- });
- return container;
- }).filter(Boolean);
- let idx = 0;
- const show = i => {
- idx = (i + clones.length) % clones.length;
- // Hide wrap and clear existing content
- wrap.style.display = 'none';
- wrap.innerHTML = '';
- // Reset box size
- box.style.width = '';
- box.style.height = '';
- // Add new post(s)
- wrap.appendChild(clones[idx]);
- if (clones.length > 1) {
- wrap.appendChild(clones[(idx + 1) % clones.length]);
- }
- // Re-show and refit
- requestAnimationFrame(() => {
- wrap.style.display = 'flex';
- requestAnimationFrame(() => fit(box));
- });
- };
- 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); }
- };
- show(0);
- box.focus();
- }
- toggleQuote(pid, link, small = false) {
- if (this.quoteMap.has(link)) {
- const existing = this.quoteMap.get(link);
- existing.remove();
- this.quoteMap.delete(link);
- return;
- }
- const src = document.getElementById(pid) || document.getElementById('p' + pid);
- if (!src) return;
- const body = src.querySelector('.innerOP, .innerPost, .postMessage');
- if (!body) return;
- const inPanel = !!link.closest(PANEL_SEL);
- const depth = inPanel
- ? +(link.dataset.depth || 0)
- : +(link.closest('.nestedPost')?.dataset.depth || 0);
- 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%' : ''
- });
- // Use cloneNode for safe copying
- wrap.appendChild(body.cloneNode(true));
- this.quoteMap.set(link, wrap);
- link.dataset.depth = depth + 1;
- const parentPost = link.closest('.nestedPost') || link.closest('.postCell, .post');
- if (parentPost) {
- parentPost.appendChild(wrap);
- } else {
- link.after(wrap);
- }
- if (inPanel) fit(link.closest(PANEL_SEL));
- }
- createSettingsIcon() {
- const icon = document.createElement('div');
- icon.id = 'fcx-settings-icon';
- icon.innerHTML = '⚙️';
- Object.assign(icon.style, {
- position: 'fixed',
- bottom: '1rem',
- right: '1rem',
- fontSize: '20px',
- cursor: 'pointer',
- zIndex: 10000
- });
- icon.onclick = () => this.toggleSettingsPanel();
- document.body.appendChild(icon);
- }
- toggleSettingsPanel() {
- const existing = document.getElementById('fcx-settings-panel');
- if (existing) {
- existing.remove();
- return;
- }
- this.createSettingsPanel();
- }
- 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;">
- 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>
- <button id="fcx-stop-previews" style="margin-top:10px; width: 100%;">⏹️ Stop All Previews</button>
- `;
- document.body.appendChild(panel);
- // Set current state
- 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-stop-previews').onclick = () => {
- this.stopAllPreviews();
- };
- // Set current state
- QS('#fcx-preload-images').checked = this.settings.preloadImages;
- QS('#fcx-preload-posts').value = this.settings.preloadPosts;
- QS('#fcx-preload-images').onchange = e => {
- this.settings.preloadImages = e.target.checked;
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- };
- 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);
- };
- // Event listeners
- 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
- };
- 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() {
- // Aggressively pause and remove all preview audio and video
- QSA('audio, video').forEach(el => {
- try {
- el.pause();
- el.currentTime = 0;
- } catch (err) {}
- el.remove();
- });
- // Remove image previews (especially floating ones)
- QSA('img').forEach(el => {
- const style = getComputedStyle(el);
- if (style.position === 'fixed' || el.dataset.fcxHover || el.style.pointerEvents === 'none') {
- el.remove();
- }
- });
- // Remove loading spinners
- QSA('.fcx-spinner').forEach(spin => spin.remove());
- // Reset preview tracking
- QSA('a[data-filemime]').forEach(a => {
- delete a._prev;
- delete a._preloaded;
- });
- }
- bindGlobal() {
- // Quote click inside thread
- 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);
- // Global post number and ID 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 pointerover
- 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;
- img.decode?.().then(() => {
- // Optional: console.log("Preloaded image:", url);
- }).catch(() => {});
- } else {
- fetch(url, { mode: 'no-cors' }).catch(() => {});
- }
- a._preloaded = true;
- }, { passive: true });
- // Media preview on hover
- this.thread.addEventListener('pointerenter', async e => {
- const a = e.target.closest('a[data-filemime]');
- if (!a) return;
- this._activePreviewAnchor?.dispatchEvent(new Event('mouseleave'));
- this._activePreviewAnchor = a;
- if (a._prev) {
- a._prev.pause?.();
- a._prev.remove();
- delete a._prev;
- }
- const originalUrl = a.href;
- const mime = a.dataset.filemime?.toLowerCase() || '';
- // Fix 2: cache-busting for Chrome reliability
- let url = originalUrl;
- if (!mime.startsWith('image/')) {
- const bust = `fcxcb=${Date.now()}`;
- url = originalUrl.includes('?') ? `${originalUrl}&${bust}` : `${originalUrl}?${bust}`;
- }
- if (!OK_MIME.test(mime) && !OK_EXT.test(url)) return;
- if (url.includes('catbox.moe') || url.includes('z.zz.fo')) return;
- let el;
- const isVideo = mime.startsWith('video/');
- const isAudio = mime.startsWith('audio/');
- const isImg = mime.startsWith('image/');
- if (isVideo || isAudio) {
- el = document.createElement(isVideo ? 'video' : 'audio');
- Object.assign(el, {
- src: url,
- loop: true,
- preload: 'auto',
- volume: this.settings?.volume ?? 0.5
- });
- } else if (isImg) {
- el = new Image();
- el.loading = 'eager';
- el.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
- el.src = url;
- }
- el.dataset.fcxHover = 'true';
- Object.assign(el.style, {
- position: 'fixed',
- left: '0px',
- top: '0px',
- maxWidth: '90vw',
- maxHeight: '90vh',
- zIndex: 10000,
- pointerEvents: 'none'
- });
- // Fix 3: append first before decode
- document.body.appendChild(el);
- try {
- if (isImg && el.decode) {
- el.onload = () => {
- if (a.contains(spinner)) spinner.remove();
- };
- el.onerror = () => {
- if (a.contains(spinner)) spinner.remove();
- el.remove();
- };
- if (a.contains(spinner)) spinner.remove();
- }
- } catch (err) {
- console.warn('Image decode failed', err);
- }
- // Show a loading spinner (after decode try, but still within hoverActive check)
- const spinner = document.createElement('div');
- Object.assign(spinner.style, {
- 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'
- });
- spinner.textContent = '⏳ Loading ⏳';
- spinner.className = 'fcx-spinner';
- let hoverActive = true;
- const loadTimeout = setTimeout(() => {
- if (!a._prev && hoverActive) {
- if (!a.matches(':hover')) return;
- a.appendChild(spinner);
- }
- }, 150);
- const cancelHover = () => {
- hoverActive = false;
- el.pause?.();
- el.remove();
- delete a._prev;
- clearTimeout(loadTimeout);
- if (a.contains(spinner)) spinner.remove();
- };
- ['mouseleave', 'pointercancel', 'pointerout'].forEach(ev => {
- a.addEventListener(ev, cancelHover, { once: true });
- el.addEventListener(ev, cancelHover, { once: true });
- });
- a._hovering = true;
- // Retry mechanism for video/audio
- const maxRetries = 3;
- let attempt = 0;
- const tryLoad = () => {
- if (!hoverActive) return;
- attempt++;
- const start = performance.now();
- el.load?.();
- const checkReady = setInterval(() => {
- if (!hoverActive) {
- clearInterval(checkReady);
- el.remove();
- if (a.contains(spinner)) spinner.remove();
- return;
- }
- if (el.readyState >= 2) {
- clearInterval(checkReady);
- clearTimeout(loadTimeout);
- if (a.contains(spinner)) spinner.remove();
- el.play?.().catch(() => {});
- a._prev = el;
- return;
- }
- if (performance.now() - start > 2500) {
- clearInterval(checkReady);
- el.remove();
- if (a.contains(spinner)) spinner.remove();
- if (attempt < maxRetries && hoverActive) {
- setTimeout(tryLoad, 300);
- }
- return;
- }
- }, 100);
- };
- tryLoad();
- }, { capture: true, passive: true });
- ['scroll', 'wheel', 'touchmove'].forEach(ev => {
- window.addEventListener(ev, () => this.stopHoverPreviews(), { passive: true });
- });
- }
- stopHoverPreviews() {
- QSA('img, video, audio').forEach(el => {
- if (el.dataset.fcxHover) {
- el.pause?.();
- el.remove();
- }
- });
- // Also catch unmarked audio elements still lingering
- QSA('audio, video').forEach(el => {
- el.pause?.();
- el.remove();
- });
- QSA('a[data-filemime]').forEach(a => {
- if (a._prev) delete a._prev;
- });
- QSA('.fcx-spinner').forEach(s => s.remove());
- }
- setupMediaPreloader() {
- const MAX_PRELOAD = this.settings.preloadPosts ?? 5;
- const BUFFER_POSTS = MAX_PRELOAD;
- const SCROLL_IDLE_DELAY = 200;
- if (!this.settings.preloadImages) return;
- let lastScrollTop = window.scrollY;
- let lastScrollTime = Date.now();
- let scrollDir = 'down';
- let scrollTimer = null;
- let isScrolling = false;
- 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 {
- fetch(url, { mode: 'no-cors' }).catch(() => {});
- }
- a._preloaded = true;
- };
- 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);
- };
- 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;
- };
- 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;
- const range = [];
- for (let offset = -BUFFER_POSTS; offset <= BUFFER_POSTS; offset++) {
- const idx = scrollDir === 'up'
- ? centerIndex - offset
- : centerIndex + offset;
- if (posts[idx]) range.push(posts[idx]);
- }
- const mediaLinks = range.flatMap(post =>
- QSA('a[data-filemime]', post).filter(a => !a._preloaded && !a._prev)
- ).slice(0, MAX_PRELOAD);
- requestIdleCallback(() => {
- mediaLinks.forEach(preload);
- });
- };
- window.addEventListener('scroll', updateScrollState, { passive: true });
- window.addEventListener('resize', doSmartPreload, { passive: true });
- requestIdleCallback(() => doSmartPreload());
- }
- markCrossThreadQuotes(container = document) {
- QSA('.quoteLink, .quotelink', container).forEach(q => {
- if (q.dataset.crossThreaded) return;
- // Case 1: >>12345 (67890)
- const explicitMatch = q.textContent.match(/^>>(\d+)\s+\((\d+)\)$/);
- if (explicitMatch) {
- const [ , postId, threadId ] = explicitMatch;
- if (threadId !== this.tid) {
- q.href = `/${location.pathname.split('/')[1]}/res/${threadId}.html#${postId}`;
- this._tagAsCrossThread(q);
- }
- return;
- }
- // Case 2: Link to a post in a different thread (likely OP)
- 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) {
- // Make sure href is set properly (already handled by caller)
- // Prevent duplicate label
- if (q.querySelector('.cross-thread-label')) return;
- // Add the visual (Cross-Thread) indicator
- const label = document.createElement('span');
- label.textContent = '(Cross-Thread)';
- label.className = 'cross-thread-label';
- label.style.fontStyle = 'italic';
- label.style.fontSize = '90%';
- label.style.color = 'var(--link-color)';
- label.style.marginLeft = '4px';
- label.style.pointerEvents = 'none';
- q.appendChild(label);
- q.dataset.crossThreaded = 'true';
- }
- }
- customElements.define('fullchan-x', FullchanX);
- const preconnect = url => {
- const link = document.createElement('link');
- link.rel = 'preconnect';
- link.href = url;
- link.crossOrigin = '';
- document.head.appendChild(link);
- };
- preconnect(location.origin); // safe since HOST is always same-origin
- document.body.appendChild(document.createElement('fullchan-x'));
- const style = document.createElement('style');
- style.textContent = `
- body.fcx-hide-stubs .postCell:has(.unhideButton.glowOnHover) {
- display: none !important;
- }
- body.fcx-anonymize .spanId {
- display: none !important;
- }
- #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