Guest User

Untitled

a guest
Apr 19th, 2025
25
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 52.48 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Fullchan‑X (v4.0)
  3. // @namespace violentmonkey‑fullchan‑x
  4. // @match https://8chan.moe/*/res/*.html*
  5. // @match https://8chan.se/*/res/*.html*
  6. // @version 4.0‑2025‑04‑19
  7. // @description Recursive inlined quotes, single posts-by-ID window, (You)s window, media previews, preloading
  8. // @author vfyxe → grok → ChatGPT
  9. // @grant none
  10. // ==/UserScript==
  11.  
  12. const STYLE_PANEL_BASE = {
  13. position: 'fixed',
  14. background: 'var(--contrast-color)',
  15. color: 'var(--link-color)',
  16. border: '1px solid var(--navbar-text-color)',
  17. padding: '6px',
  18. minWidth: '160px',
  19. maxWidth: '90vw',
  20. zIndex: 9999,
  21. transition: 'width 0.2s ease, height 0.2s ease',
  22. font: '14px/1 sans-serif'
  23. };
  24.  
  25. const STYLE_SPINNER = {
  26. position: 'absolute',
  27. top: '0',
  28. left: '0',
  29. zIndex: 10000,
  30. fontSize: '14px',
  31. color: 'white',
  32. background: 'rgba(0,0,0,0.6)',
  33. padding: '2px 6px',
  34. borderRadius: '4px',
  35. pointerEvents: 'none'
  36. };
  37.  
  38. const STYLE_HOVER_MEDIA = {
  39. position: 'fixed',
  40. left: '0px',
  41. top: '0px',
  42. maxWidth: '90vw',
  43. maxHeight: '90vh',
  44. zIndex: 10000,
  45. pointerEvents: 'none'
  46. };
  47.  
  48.  
  49. // ✅ Only initialize if we're on a valid thread page
  50. if (!document.querySelector('#divThreads')) return;
  51.  
  52. // ✅ Utility query selectors for brevity
  53. const QS = (s, r = document) => r.querySelector(s);
  54. const QSA = (s, r = document) => [...r.querySelectorAll(s)];
  55.  
  56. // ✅ Normalizes post IDs (6-digit hex string)
  57. const idNorm = s => (s.match(/^[0-9a-f]{6}/i) || [''])[0].toLowerCase();
  58.  
  59. // ✅ Site-specific constants
  60. const HOST = location.hostname;
  61. const OK_MIME = /^(video\/(mp4|webm)|image\/(png|jpe?g|gif)|audio\/mpeg)$/i;
  62. const OK_EXT = /\.(mp4|webm|png|jpe?g|gif|mp3)$/i;
  63. const PANEL_SEL = '#fcx-yous, [id^="fcx-ids-"]';
  64.  
  65. // ✅ Global LRU-style cache for preloaded media
  66. const preloadCache = new Map();
  67.  
  68. let fcxWebSocket;
  69.  
  70. // ✅ Patch the native WebSocket to track its state and log errors
  71. (() => {
  72. const NativeWS = window.WebSocket;
  73. window.WebSocket = class extends NativeWS {
  74. constructor(...args) {
  75. super(...args);
  76. fcxWebSocket = this;
  77. console.log('[Fullchan-X] WebSocket opened:', args[0]);
  78.  
  79. this._fcxUrl = args[0];
  80.  
  81. this.addEventListener('close', () => {
  82. console.warn('[Fullchan-X] WebSocket closed');
  83. });
  84. this.addEventListener('error', err => {
  85. console.warn('[Fullchan-X] WebSocket error', err);
  86. });
  87. }
  88. };
  89. })();
  90.  
  91.  
  92. function addToCache(key, value) {
  93. const maxSize = document.querySelector('fullchan-x')?.settings?.mediaCacheSize ?? 50;
  94.  
  95. if (preloadCache.size >= maxSize) {
  96. const firstKey = preloadCache.keys().next().value;
  97. const old = preloadCache.get(firstKey);
  98. old?.remove?.(); // ✅ Good: removes lingering DOM nodes if applicable
  99. preloadCache.delete(firstKey);
  100. }
  101. preloadCache.set(key, value);
  102. }
  103.  
  104. function drag(el, grip = el) {
  105. let sx, sy, ox, oy, moved = false;
  106. grip.style.cursor = 'move';
  107. grip.onmousedown = e => {
  108. sx = e.clientX; sy = e.clientY;
  109. ({ left: ox, top: oy } = el.getBoundingClientRect());
  110.  
  111. const move = ev => {
  112. const dx = ev.clientX - sx, dy = ev.clientY - sy;
  113. if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
  114. el.style.left = ox + dx + 'px';
  115. el.style.top = oy + dy + 'px';
  116. };
  117.  
  118. const up = () => {
  119. window.removeEventListener('mousemove', move);
  120. if (!moved) grip.dispatchEvent(new CustomEvent('tap'));
  121. moved = false;
  122. };
  123.  
  124. window.addEventListener('mousemove', move);
  125. window.addEventListener('mouseup', up, { once: true });
  126. };
  127. }
  128.  
  129. function fit(box) {
  130. const wrap = box.querySelector('.fcx-id-posts');
  131. if (!wrap) return;
  132.  
  133. // ✅ Temporarily override flex for measurement
  134. wrap.style.display = 'inline-block';
  135. wrap.style.flexDirection = 'column';
  136. wrap.style.alignItems = 'start';
  137. wrap.style.maxWidth = 'none';
  138.  
  139. requestAnimationFrame(() => {
  140. const width = Math.min(wrap.offsetWidth + 32, window.innerWidth * 0.9);
  141. const height = wrap.scrollHeight + 64;
  142.  
  143. box.style.width = width + 'px';
  144. box.style.height = height + 'px';
  145.  
  146. // ✅ Restore original layout
  147. wrap.style.display = 'flex';
  148. wrap.style.maxWidth = '';
  149. });
  150. }
  151.  
  152. let isHoveringPreview = false; // Place near top of your script
  153.  
  154. class FullchanX extends HTMLElement {
  155. quoteMap = new WeakMap();
  156.  
  157. indexIds() {
  158. QSA('.opCell, .postCell, .post').forEach(p => {
  159. const l = p.querySelector('.labelId');
  160. if (!l) return;
  161. const id = idNorm(l.textContent);
  162. if (!this.idMap.has(id)) this.idMap.set(id, []);
  163. this.idMap.get(id).push(p);
  164. });
  165. }
  166.  
  167. connectedCallback() {
  168.  
  169. // ✅ Cache these so we don't repeatedly query for them
  170. this.thread = QS('.divPosts');
  171. this.qBox = QS('#quick-reply');
  172. this.qBody = QS('#qrbody');
  173.  
  174. // ✅ Thread ID (used for "seen" (You)s tracking)
  175. const op = QS('.opCell');
  176. this.tid = op?.id || 'thread';
  177. this.seenKey = `${this.tid}-seen-yous`;
  178.  
  179. // Track the most recently observed post in the DOM
  180. this.lastPostEl = null;
  181.  
  182. // Initialize last known post ID from the last visible post
  183. const allPosts = QSA('.postCell, .opCell, .post', this.thread);
  184. const last = allPosts[allPosts.length - 1];
  185. const id = last?.id?.replace(/^p/, '');
  186. this.lastKnownPostId = /^\d+$/.test(id) ? parseInt(id) : 0;
  187.  
  188. // Internal tracking for refresh throttling
  189. let lastRefreshCheck = 0;
  190. let lastRefreshAttempt = 0;
  191. let lastKnownPostTime = 0;
  192.  
  193. // Load settings
  194. this.loadSettings();
  195.  
  196. this.staleAge = this.settings.staleAge ?? 10000; // 10s default
  197. this.refreshPoll = this.settings.refreshPoll ?? 5000; // 5s default
  198.  
  199. // Smart refresh: check thread JSON, compare against last known post, refresh if needed
  200. const checkThreadFreshness = () => {
  201. const now = Date.now();
  202.  
  203. // WebSocket fallback — if it disconnects, trigger a manual refresh
  204. if (!fcxWebSocket || fcxWebSocket.readyState !== 1) {
  205. console.warn('[FCX] WebSocket down, forcing refresh');
  206. window.thread?.refreshPosts?.(true);
  207. }
  208.  
  209. // Throttle to avoid excessive polling in background tabs
  210. if (now - lastRefreshAttempt < this.staleAge) return;
  211. lastRefreshAttempt = now;
  212.  
  213. // Extract board and thread ID from URL
  214. const tid = location.pathname.match(/\/res\/(\d+)\.html/)?.[1];
  215. const board = location.pathname.split('/')[1];
  216. if (!tid || !board) return;
  217.  
  218. // Fetch JSON thread snapshot
  219. fetch(`/${board}/res/${tid}.json`)
  220. .then(r => r.ok ? r.json() : null)
  221. .then(json => {
  222. if (!json || !Array.isArray(json.posts)) return;
  223.  
  224. const posts = json.posts;
  225. const newest = posts[posts.length - 1];
  226. if (!newest?.no || !newest?.time) return;
  227.  
  228. const newestId = parseInt(newest.no);
  229. const newestTime = newest.time * 1000;
  230.  
  231. // Only refresh if post is newer than last seen
  232. if (newestId > (this.lastKnownPostId || 0) || newestTime > lastKnownPostTime) {
  233. console.log(`[Fullchan-X] New post detected: ${newestId}`);
  234. this.lastKnownPostId = newestId;
  235. lastKnownPostTime = newestTime;
  236.  
  237. // Use site's native refresh if available
  238. if (window.thread?.refreshPosts) {
  239. window.thread.refreshPosts(true);
  240. } else {
  241. console.warn('[FCX] thread.refreshPosts() not available');
  242. }
  243. } else {
  244. console.log(`[Fullchan-X] No new posts (last seen: ${this.lastKnownPostId}, newest: ${newestId})`);
  245. }
  246. });
  247. };
  248.  
  249. // Persistent refresh loop using setTimeout (not setInterval)
  250. const runRefreshLoop = () => {
  251. checkThreadFreshness();
  252. setTimeout(runRefreshLoop, this.refreshPoll);
  253. };
  254. runRefreshLoop();
  255.  
  256. // Refresh immediately on tab re-focus
  257. document.addEventListener('visibilitychange', () => {
  258. if (!document.hidden) checkThreadFreshness();
  259. });
  260.  
  261. // Thread ID, tracked for seen (You)s and local storage
  262. this.seen = JSON.parse(localStorage.getItem(this.seenKey) || '[]');
  263.  
  264. // Map of ID → post list
  265. this.idMap = new Map();
  266.  
  267. requestIdleCallback(() => {
  268. this.indexIds();
  269. this.observeIds();
  270. this.markCrossThreadQuotes();
  271. this.refreshYous();
  272. }, { timeout: 500 });
  273.  
  274.  
  275. // Add draggable settings icon
  276. this.createSettingsIcon();
  277.  
  278. // Create and initialize the (You)s panel
  279. this.youBox = this.panel('fcx-yous', 'My (You)s', { right: '1rem', top: '3rem' });
  280.  
  281. window.fcxInstance = this;
  282.  
  283. // Auto-close reply after successful post
  284. if (this.settings.closeReplyBox) {
  285. const qr = this.qBox;
  286. const form = qr?.querySelector('form');
  287. const container = this.thread;
  288. const body = this.qBody;
  289.  
  290. form?.addEventListener('submit', () => {
  291. const before = QSA('.postCell, .post', container).length;
  292.  
  293. const obs = new MutationObserver(() => {
  294. const after = QSA('.postCell, .post', container).length;
  295. if (after > before) {
  296. obs.disconnect();
  297.  
  298. // ✅ Wait briefly to ensure post is stable in DOM
  299. setTimeout(() => {
  300. console.log('[FCX] Post detected, closing reply box');
  301. if (qr) qr.style.display = 'none';
  302. if (body) body.value = ''; // ✅ Clear text
  303. }, 300); // small delay to avoid race with DOM repaint
  304. }
  305. });
  306.  
  307. obs.observe(container, { childList: true });
  308. });
  309. }
  310.  
  311. this.setupMediaPreloader();
  312.  
  313. // Global event bindings (e.g. quote clicks, media previews)
  314. this.bindGlobal();
  315.  
  316. // Auto-pause audio/video when tab loses focus
  317. document.addEventListener('visibilitychange', () => {
  318. if (document.hidden) {
  319. QSA('audio, video').forEach(el => {
  320. try { el.pause(); } catch {}
  321. });
  322. }
  323. });
  324.  
  325. // Throttled DOM observer update scheduler
  326. let throttleTimer = null;
  327. const scheduleUpdate = () => {
  328. if (throttleTimer) cancelIdleCallback(throttleTimer);
  329. throttleTimer = requestIdleCallback(() => this.refreshYous(), { timeout: 200 });
  330. };
  331. }
  332.  
  333. disconnectedCallback() {
  334. // Cleanly disconnect the IntersectionObserver when the element is removed from DOM
  335. if (this.io) this.io.disconnect?.();
  336. }
  337.  
  338. observeIds() {
  339. const container = this.thread;
  340. if (!container) return;
  341.  
  342. const handleNewPost = node => {
  343. if (!(node instanceof HTMLElement)) return;
  344. const postElement = node.matches('.opCell, .postCell, .post')
  345. ? node
  346. : node.querySelector('.opCell, .postCell, .post');
  347. if (!postElement) return;
  348.  
  349. this.lastPostEl = postElement;
  350. const label = postElement.querySelector('.labelId');
  351. if (!label) return;
  352.  
  353. const id = idNorm(label.textContent);
  354. if (!this.idMap.has(id)) {
  355. this.idMap.set(id, []);
  356. }
  357. this.idMap.get(id).push(postElement);
  358.  
  359. this.markCrossThreadQuotes(postElement);
  360. };
  361.  
  362. let mutationDebounce = null;
  363. const pendingNodes = new Set();
  364.  
  365. const flushMutations = () => {
  366. for (const node of pendingNodes) {
  367. handleNewPost(node);
  368. }
  369. pendingNodes.clear();
  370. };
  371.  
  372. new MutationObserver(mutations => {
  373. mutations.forEach(m => {
  374. m.addedNodes.forEach(n => pendingNodes.add(n));
  375. });
  376. if (mutationDebounce) clearTimeout(mutationDebounce);
  377. mutationDebounce = setTimeout(flushMutations, 100);
  378. }).observe(container, {
  379. childList: true,
  380. subtree: false
  381. });
  382. }
  383.  
  384. panel(id, title, pos) {
  385. // Create a floating panel for IDs or (You)s
  386. const box = Object.assign(document.createElement('div'), { id });
  387.  
  388. // Header includes title and a nav slot
  389. box.innerHTML = `
  390. <div class="hdr" style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
  391. <span class="hdrText">${title}</span>
  392. <div class="fcx-nav" style="display: flex; gap: 4px;"></div>
  393. </div>
  394. <div class="lst"></div>
  395. `;
  396.  
  397. // Main box styling
  398. Object.assign(box.style, { ...STYLE_PANEL_BASE, ...pos });
  399.  
  400. // Cache common sub-elements
  401. const hdr = box.querySelector('.hdr'),
  402. text = box.querySelector('.hdrText'),
  403. lst = box.querySelector('.lst');
  404.  
  405. // Style the post list content container
  406. Object.assign(lst.style, {
  407. lineHeight: '1.5',
  408. display: 'block',
  409. minWidth: '100%',
  410. whiteSpace: 'normal',
  411. overflowX: 'hidden',
  412. overflowY: 'auto'
  413. });
  414.  
  415. // Resize box on load to match content
  416. requestAnimationFrame(() => box.dataset.baseWidth = box.offsetWidth);
  417.  
  418. // Watch for dynamic content and auto-resize as needed
  419. new MutationObserver(() => fit(box)).observe(lst, { childList: true });
  420.  
  421. // Attach to document
  422. document.body.appendChild(box);
  423.  
  424. // Make draggable via header
  425. drag(box, hdr);
  426.  
  427. // Header click behavior: remove box (for ID views) or toggle display (for (You)s)
  428. if (id.startsWith('fcx-ids-')) {
  429. text.onclick = () => box.remove();
  430. } else if (id === 'fcx-yous') {
  431. text.onclick = () => this.toggleYou(box);
  432. }
  433.  
  434. return box;
  435. }
  436.  
  437. toggleYou(box) {
  438. // Toggle the visibility of the (You)s list panel
  439. const lst = box.querySelector('.lst');
  440.  
  441. if (lst.style.display === 'none') {
  442. lst.style.display = 'block';
  443. fit(box); // Resize to content
  444. } else {
  445. lst.style.display = 'none';
  446. box.style.width = box.style.height = 'auto'; // Collapse when hidden
  447. }
  448. }
  449.  
  450. pid(post) {
  451. // Extract post number (ID) from <a> link or fallback to post.id
  452. const a = QSA('a', post).find(x => /^\d+$/.test(x.textContent.trim()));
  453. return a ? a.textContent.trim() : (post.id || '').replace(/^p/, '');
  454. }
  455.  
  456. jump(id) {
  457. // Scroll to a post by ID and briefly outline it
  458. const p = document.getElementById(id);
  459. if (!p) return;
  460.  
  461. p.scrollIntoView({ behavior: 'smooth', block: 'center' });
  462. p.style.outline = '2px solid var(--link-color)';
  463.  
  464. // Clear outline after 1.5s
  465. setTimeout(() => {
  466. p.style.outline = '';
  467. }, 1500);
  468. }
  469.  
  470. refreshYous() {
  471. const lst = this.youBox.querySelector('.lst');
  472. const seenIds = new Set(this.seen);
  473. const existing = new Set([...lst.querySelectorAll('a')].map(a => a.textContent));
  474. const frag = document.createDocumentFragment();
  475.  
  476. QSA('.quoteLink.you, .quotelink.you', this.thread).forEach(q => {
  477. const post = q.closest('.postCell, .post');
  478. if (!post) return;
  479. const id = post.id;
  480. const ref = '>>' + id;
  481.  
  482. if (!existing.has(ref)) {
  483. const a = document.createElement('a');
  484. a.textContent = ref;
  485. a.href = '#' + id;
  486. a.style.display = 'block';
  487. a.style.fontWeight = seenIds.has(id) ? 'normal' : '700';
  488. a.onclick = e => {
  489. e.preventDefault();
  490. this.jump(id);
  491. };
  492. frag.appendChild(a);
  493. }
  494. });
  495.  
  496. lst.appendChild(frag);
  497.  
  498. if (!this.io) {
  499. this.io = new IntersectionObserver(entries => {
  500. let updated = false;
  501.  
  502. for (const ent of entries) {
  503. if (ent.isIntersecting) {
  504. const id = ent.target.id;
  505. if (!seenIds.has(id)) {
  506. this.seen.push(id);
  507. updated = true;
  508. if (this.seen.length > 500) {
  509. this.seen = this.seen.slice(-500);
  510. }
  511. this.io.unobserve(ent.target);
  512. }
  513. }
  514. }
  515.  
  516. if (updated) {
  517. localStorage.setItem(this.seenKey, JSON.stringify(this.seen));
  518. // Only refresh DOM once after batch updates
  519. requestIdleCallback(() => this.refreshYous());
  520. }
  521. }, { threshold: 0.15 });
  522. }
  523.  
  524. const unseen = QSA('.postCell, .post', this.thread)
  525. .filter(p => !seenIds.has(p.id))
  526. .slice(0, 200);
  527.  
  528. unseen.forEach(p => this.io.observe(p));
  529. }
  530.  
  531. openIdBox(raw) {
  532. const id = idNorm(raw); // Normalize input to a 6-char hex ID
  533. const boxId = `fcx-ids-${id}`;
  534. const prevOpen = QS('[id^="fcx-ids-"]');
  535.  
  536. // If a different ID box is open, remove it and clear tracked posts
  537. if (prevOpen && prevOpen.id !== boxId) {
  538. prevOpen.remove();
  539. this.idMap.clear();
  540. } else if (prevOpen) {
  541. // Clicking again on the same ID closes it
  542. prevOpen.remove();
  543. this.idMap.clear();
  544. return;
  545. }
  546.  
  547. // Build map of posts with the given ID
  548. this.idMap.clear();
  549. this.idMap.set(id, []);
  550. QSA('.opCell, .postCell, .post').forEach(p => {
  551. const label = p.querySelector('.labelId');
  552. if (!label) return;
  553. if (idNorm(label.textContent) === id) {
  554. this.idMap.get(id).push(p);
  555. }
  556. });
  557.  
  558. const posts = this.idMap.get(id) || [];
  559.  
  560. // Create floating panel to display ID-matched posts
  561. const box = this.panel(boxId, `Posts by ID: ${id}`, { right: '1rem', top: '12rem' });
  562. box.classList.add('fcx-idview');
  563.  
  564. const hdr = box.querySelector('.hdr');
  565. const lst = box.querySelector('.lst');
  566.  
  567. // Add ◀ ▶ nav buttons
  568. const nav = document.createElement('div');
  569. nav.className = 'fcx-nav';
  570. nav.innerHTML = `
  571. <button style="width:2em;">◀</button>
  572. <button style="width:2em;">▶</button>
  573. `;
  574. hdr.appendChild(nav);
  575. const [prev, next] = nav.querySelectorAll('button');
  576.  
  577. // Set up container for cloned post views
  578. const wrap = document.createElement('div');
  579. Object.assign(wrap.style, {
  580. display: 'flex',
  581. flexDirection: 'column',
  582. gap: '8px',
  583. alignItems: 'stretch',
  584. });
  585. wrap.className = 'fcx-id-posts';
  586. lst.appendChild(wrap);
  587.  
  588. // Lazy-load post content for performance
  589. let clones = new Array(posts.length).fill(null);
  590.  
  591. const loadPost = i => {
  592. const post = posts[i];
  593. if (!post || clones[i]) return;
  594.  
  595. const num = this.pid(post);
  596. const body = post.querySelector('.innerOP, .innerPost, .postMessage');
  597. if (!num || !body) return;
  598.  
  599. const container = document.createElement('div');
  600. Object.assign(container.style, {
  601. border: '1px solid var(--navbar-text-color)',
  602. padding: '6px',
  603. borderRadius: '4px',
  604. backgroundColor: 'rgba(255,255,255,0.02)',
  605. boxSizing: 'border-box',
  606. overflow: 'auto',
  607. overflowWrap: 'break-word',
  608. maxHeight: '60vh'
  609. });
  610.  
  611. const content = body.cloneNode(true);
  612. container.appendChild(content);
  613.  
  614. // Make inlined quotes inside cloned post clickable
  615. QSA('a.quoteLink, a.quotelink', container).forEach(a => {
  616. if (!a.textContent.startsWith('>>')) return;
  617. a.onclick = e => {
  618. e.preventDefault();
  619. const match = a.href.match(/#q?(\d+)/);
  620. if (match) this.jump(match[1]);
  621. };
  622. });
  623.  
  624. clones[i] = container;
  625. };
  626.  
  627. let idx = 0;
  628. const show = i => {
  629. idx = (i + posts.length) % posts.length;
  630. const nextIdx = (idx + 1) % posts.length;
  631.  
  632. wrap.style.display = 'none';
  633. wrap.innerHTML = '';
  634.  
  635. loadPost(idx);
  636. loadPost(nextIdx); // Preload next
  637.  
  638. wrap.appendChild(clones[idx]);
  639. if (clones[nextIdx] && posts.length > 1) {
  640. wrap.appendChild(clones[nextIdx]);
  641. }
  642.  
  643. requestAnimationFrame(() => {
  644. wrap.style.display = 'flex';
  645. requestAnimationFrame(() => fit(box));
  646. });
  647. };
  648.  
  649. // Keyboard & nav button controls
  650. prev.onclick = () => show(idx - 2);
  651. next.onclick = () => show(idx + 2);
  652.  
  653. box.tabIndex = 0;
  654. box.onkeydown = e => {
  655. if (e.key === 'ArrowLeft') { e.preventDefault(); show(idx - 2); }
  656. if (e.key === 'ArrowRight') { e.preventDefault(); show(idx + 2); }
  657. };
  658.  
  659. drag(box, hdr);
  660. show(0); // Start with first matched post
  661. box.focus();
  662. }
  663.  
  664. toggleQuote(pid, link, small = false) {
  665. // Remove previously inlined quote if already open
  666. if (this.quoteMap.has(link)) {
  667. const existing = this.quoteMap.get(link);
  668. existing.remove();
  669. this.quoteMap.delete(link);
  670. return;
  671. }
  672.  
  673. // Find quoted source post
  674. const src = document.getElementById(pid) || document.getElementById('p' + pid);
  675. if (!src) return;
  676.  
  677. const body = src.querySelector('.innerOP, .innerPost, .postMessage');
  678. if (!body) return;
  679.  
  680. // Determine quote nesting level
  681. const inPanel = !!link.closest(PANEL_SEL);
  682. const depth = inPanel
  683. ? +(link.dataset.depth || 0)
  684. : +(link.closest('.nestedPost')?.dataset.depth || 0);
  685.  
  686. // Create the wrapper that indents and contains the quote
  687. const wrap = document.createElement('div');
  688. wrap.dataset.depth = depth + 1;
  689. wrap.className = 'nestedPost';
  690. Object.assign(wrap.style, {
  691. border: '1px solid var(--navbar-text-color)',
  692. margin: `.3em 0 .3em ${(depth + 1) * 20}px`,
  693. padding: '4px',
  694. width: '100%',
  695. boxSizing: 'border-box',
  696. overflow: 'visible',
  697. display: 'block',
  698. fontSize: small ? '90%' : ''
  699. });
  700.  
  701. // Inner block matches site's post content layout
  702. const inner = document.createElement('div');
  703. Object.assign(inner.style, {
  704. display: 'inline-block',
  705. maxWidth: '100%',
  706. border: '1px solid var(--navbar-text-color)',
  707. borderRadius: '4px',
  708. padding: '4px',
  709. background: 'var(--contrast-color)',
  710. boxSizing: 'border-box'
  711. });
  712.  
  713. inner.appendChild(body.cloneNode(true));
  714. wrap.appendChild(inner);
  715.  
  716. // Save for toggle tracking
  717. this.quoteMap.set(link, wrap);
  718. link.dataset.depth = depth + 1;
  719.  
  720. // Insert after the post or quote that invoked it
  721. const parentPost = link.closest('.nestedPost') || link.closest('.postCell, .post');
  722. if (parentPost) {
  723. parentPost.appendChild(wrap);
  724. } else {
  725. link.after(wrap);
  726. }
  727.  
  728. // Resize panel if inside a floating viewer
  729. if (inPanel) fit(link.closest(PANEL_SEL));
  730. }
  731.  
  732. createSettingsIcon() {
  733. const icon = document.createElement('div');
  734. icon.id = 'fcx-settings-icon';
  735. icon.innerHTML = '⚙️';
  736.  
  737. // Visual and positioning styles
  738. Object.assign(icon.style, {
  739. position: 'fixed',
  740. bottom: '1rem',
  741. right: '1rem',
  742. fontSize: '20px',
  743. cursor: 'pointer',
  744. zIndex: 10000
  745. });
  746.  
  747. // Open/close the settings panel on click
  748. icon.onclick = () => this.toggleSettingsPanel();
  749. document.body.appendChild(icon);
  750. }
  751.  
  752. toggleSettingsPanel() {
  753. const existing = document.getElementById('fcx-settings-panel');
  754. if (existing) {
  755. existing.remove(); // Close if already open
  756. return;
  757. }
  758. this.createSettingsPanel(); // Open if closed
  759. }
  760.  
  761. createSettingsPanel() {
  762. const panel = document.createElement('div');
  763. panel.id = 'fcx-settings-panel';
  764.  
  765. Object.assign(panel.style, {
  766. position: 'fixed',
  767. bottom: '3rem',
  768. right: '1rem',
  769. background: 'var(--contrast-color)',
  770. color: 'var(--link-color)',
  771. border: '1px solid var(--navbar-text-color)',
  772. padding: '10px',
  773. borderRadius: '6px',
  774. zIndex: 10000,
  775. font: '14px sans-serif',
  776. minWidth: '220px'
  777. });
  778.  
  779. panel.innerHTML = `
  780. <label style="display:block; margin-bottom:8px;">
  781. <input type="checkbox" id="fcx-toggle-yous">
  782. Show My (You)s
  783. </label>
  784. <label style="display:block; margin-bottom:8px;">
  785. <input type="checkbox" id="fcx-close-reply-box">
  786. Auto-close Quick Reply after posting
  787. </label>
  788.  
  789. <label style="display:block; margin-bottom:8px;">
  790. Media Volume:
  791. <input type="range" id="fcx-volume-slider" min="0" max="100" style="width: 100%;">
  792. </label>
  793.  
  794. <label style="display:block; margin-bottom:8px;">
  795. <input type="checkbox" id="fcx-hide-stubs">
  796. Hide Filtered Stubs
  797. </label>
  798.  
  799. <label style="display:block; margin-bottom:8px;">
  800. <input type="checkbox" id="fcx-anonymize">
  801. Anonymize (hide IDs)
  802. </label>
  803.  
  804. <label style="display:block; margin-bottom:8px;">
  805. <input type="checkbox" id="fcx-preload-images">
  806. Preload Images
  807. </label>
  808.  
  809. <label style="display:block; margin-bottom:8px;">
  810. Max Posts to Preload:
  811. <input type="number" id="fcx-preload-posts" min="1" max="50" value="5" style="width: 100%;">
  812. </label>
  813. <label style="display:block; margin-bottom:8px;">
  814. Refresh Interval (ms) (Requires Reload):
  815. <input type="number" id="fcx-refresh-poll" min="1000" max="60000" value="5000" style="width: 100%;">
  816. </label>
  817.  
  818. <label style="display:block; margin-bottom:8px;">
  819. Stale Threshold (ms) (Requires Reload):
  820. <input type="number" id="fcx-stale-age" min="2000" max="120000" value="10000" style="width: 100%;">
  821. </label>
  822.  
  823.  
  824. <label style="display:block; margin-bottom:8px;">
  825. Max Media Cache Size:
  826. <input type="number" id="fcx-cache-size" min="5" max="200" value="50" style="width: 100%;">
  827. </label>
  828.  
  829. <button id="fcx-stop-previews" style="margin-top:10px; width: 100%;">⏹️ Stop All Previews</button>
  830. `;
  831.  
  832. document.body.appendChild(panel);
  833.  
  834. // Bind values from settings
  835. QS('#fcx-toggle-yous').checked = this.settings.showYous;
  836. QS('#fcx-volume-slider').value = Math.round(this.settings.volume * 100);
  837. QS('#fcx-hide-stubs').checked = this.settings.hideStubs;
  838. QS('#fcx-anonymize').checked = this.settings.anonymize;
  839. QS('#fcx-preload-images').checked = this.settings.preloadImages;
  840. QS('#fcx-preload-posts').value = this.settings.preloadPosts;
  841. QS('#fcx-cache-size').value = this.settings.mediaCacheSize;
  842. QS('#fcx-close-reply-box').checked = this.settings.closeReplyBox;
  843. QS('#fcx-close-reply-box').onchange = e => {
  844. this.settings.closeReplyBox = e.target.checked;
  845. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  846. };
  847.  
  848. // Bind actions to inputs
  849. QS('#fcx-stop-previews').onclick = () => this.stopAllPreviews();
  850.  
  851. QS('#fcx-cache-size').oninput = e => {
  852. const val = Math.max(5, Math.min(200, parseInt(e.target.value) || 50));
  853. this.settings.mediaCacheSize = val;
  854. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  855. };
  856.  
  857. QS('#fcx-preload-images').onchange = e => {
  858. this.settings.preloadImages = e.target.checked;
  859. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  860. };
  861. QS('#fcx-refresh-poll').value = this.settings.refreshPoll;
  862. QS('#fcx-stale-age').value = this.settings.staleAge;
  863.  
  864. QS('#fcx-refresh-poll').oninput = e => {
  865. this.settings.refreshPoll = Math.max(1000, parseInt(e.target.value) || 5000);
  866. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  867. this.refreshPoll = this.settings.refreshPoll;
  868. };
  869.  
  870. QS('#fcx-stale-age').oninput = e => {
  871. this.settings.staleAge = Math.max(2000, parseInt(e.target.value) || 10000);
  872. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  873. this.staleAge = this.settings.staleAge;
  874. };
  875.  
  876. QS('#fcx-preload-posts').oninput = e => {
  877. this.settings.preloadPosts = Math.max(1, Math.min(50, parseInt(e.target.value) || 5));
  878. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  879. };
  880.  
  881. QS('#fcx-anonymize').onchange = e => {
  882. this.settings.anonymize = e.target.checked;
  883. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  884. document.body.classList.toggle('fcx-anonymize', this.settings.anonymize);
  885. };
  886.  
  887. QS('#fcx-toggle-yous').onchange = e => {
  888. this.settings.showYous = e.target.checked;
  889. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  890. this.youBox.style.display = this.settings.showYous ? 'block' : 'none';
  891. fit(this.youBox);
  892. };
  893.  
  894. QS('#fcx-volume-slider').oninput = e => {
  895. this.settings.volume = +e.target.value / 100;
  896. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  897. };
  898.  
  899. QS('#fcx-hide-stubs').onchange = e => {
  900. this.settings.hideStubs = e.target.checked;
  901. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  902. document.body.classList.toggle('fcx-hide-stubs', this.settings.hideStubs);
  903. };
  904. }
  905.  
  906. loadSettings() {
  907. const saved = JSON.parse(localStorage.getItem('fcx-settings') || '{}');
  908.  
  909. this.settings = {
  910. showYous: saved.showYous ?? true,
  911. volume: saved.volume ?? 0.5,
  912. hideStubs: saved.hideStubs ?? false,
  913. anonymize: saved.anonymize ?? false,
  914. preloadImages: saved.preloadImages ?? true,
  915. preloadPosts: saved.preloadPosts ?? 5,
  916. mediaCacheSize: saved.mediaCacheSize ?? 50,
  917. closeReplyBox: saved.closeReplyBox ?? false,
  918. staleAge: saved.staleAge ?? 10000,
  919. refreshPoll: saved.refreshPoll ?? 5000
  920. };
  921.  
  922. // Immediately apply relevant settings
  923. if (!this.settings.showYous) {
  924. this.youBox.style.display = 'none';
  925. }
  926. if (this.settings.hideStubs) {
  927. document.body.classList.add('fcx-hide-stubs');
  928. }
  929. if (this.settings.anonymize) {
  930. document.body.classList.add('fcx-anonymize');
  931. }
  932. }
  933.  
  934. stopAllPreviews() {
  935. // Stop and remove all audio/video elements (from hover previews)
  936. QSA('audio, video').forEach(el => {
  937. try {
  938. el.pause();
  939. el.currentTime = 0;
  940. } catch (err) {}
  941. el.remove();
  942. });
  943.  
  944. // Remove image previews (usually floating ones triggered by hover)
  945. QSA('img').forEach(el => {
  946. const style = getComputedStyle(el);
  947. if (style.position === 'fixed' || el.dataset.fcxHover || el.style.pointerEvents === 'none') {
  948. el.remove();
  949. }
  950. });
  951.  
  952. // Remove any loading spinners from pending media loads
  953. QSA('.fcx-spinner').forEach(spin => spin.remove());
  954.  
  955. // Clear preview-related tracking from anchor elements
  956. QSA('a[data-filemime]').forEach(a => {
  957. delete a._prev;
  958. delete a._preloaded;
  959. });
  960. }
  961.  
  962. bindGlobal() {
  963.  
  964. // 🧠 Fallback for quote tooltips if native one fails to appear
  965. this.thread.addEventListener('pointerover', e => {
  966. const qLink = e.target.closest('a.quoteLink, a.quotelink');
  967. if (!qLink) return;
  968.  
  969. const match = qLink.href.match(/#q?(\d+)/);
  970. if (!match) return;
  971.  
  972. const pid = match[1];
  973. const existing = document.getElementById(pid) || document.getElementById('p' + pid);
  974. if (!existing) return;
  975.  
  976. setTimeout(() => {
  977. const tooltip = document.querySelector('body > div.quoteTooltip');
  978. const isVisible = tooltip && tooltip.offsetParent !== null;
  979. if (isVisible || qLink._fcxHoverQuote) return;
  980.  
  981. const body = existing.querySelector('.innerPost, .postMessage, .divMessage');
  982. if (!body) return;
  983.  
  984. const preview = document.createElement('div');
  985. preview.className = 'fcx-hover-quote';
  986. Object.assign(preview.style, {
  987. position: 'absolute',
  988. zIndex: '10000',
  989. pointerEvents: 'none',
  990. maxWidth: '60vw',
  991. maxHeight: '50vh',
  992. overflow: 'auto',
  993. background: 'var(--contrast-color)',
  994. border: '1px solid var(--navbar-text-color)',
  995. padding: '6px',
  996. borderRadius: '6px',
  997. font: '14px sans-serif',
  998. boxShadow: '0 2px 8px rgba(0,0,0,0.3)'
  999. });
  1000.  
  1001. preview.appendChild(body.cloneNode(true));
  1002. document.body.appendChild(preview);
  1003. qLink._fcxHoverQuote = preview;
  1004.  
  1005. const rect = qLink.getBoundingClientRect();
  1006. preview.style.left = `${rect.right + 10}px`;
  1007. preview.style.top = `${rect.top + window.scrollY}px`;
  1008.  
  1009. const cleanup = () => {
  1010. preview.remove();
  1011. delete qLink._fcxHoverQuote;
  1012. window.removeEventListener('mousemove', checkMouse);
  1013. };
  1014.  
  1015. const checkMouse = e => {
  1016. const overLink = qLink.matches(':hover');
  1017. const overPreview = preview.matches(':hover');
  1018. if (!overLink && !overPreview) cleanup();
  1019. };
  1020.  
  1021. window.addEventListener('mousemove', checkMouse);
  1022. }, 100);
  1023. }, { passive: true });
  1024.  
  1025. // 📌 Quote link click (expand inline or jump to post)
  1026. this.thread.addEventListener('click', e => {
  1027. const link = e.target.closest('a.quoteLink, a.quotelink');
  1028. if (!link || !link.textContent.startsWith('>>')) return;
  1029.  
  1030. const match = link.href.match(/#q?(\d+)/);
  1031. if (!match) return;
  1032.  
  1033. e.preventDefault();
  1034. const quoteId = match[1];
  1035. const inPanel = link.closest('[id^="fcx-ids-"]');
  1036.  
  1037. if (inPanel) {
  1038. this.jump(quoteId);
  1039. } else {
  1040. this.toggleQuote(quoteId, link);
  1041. }
  1042. }, true);
  1043.  
  1044. // 🧠 Post number and ID click handler
  1045. document.addEventListener('click', e => {
  1046. const pn = e.target.closest('a.postNum, a.post_no, a.no, a.linkQuote');
  1047. if (pn) {
  1048. e.preventDefault();
  1049. e.stopPropagation();
  1050. e.stopImmediatePropagation();
  1051. const id = pn.hash.slice(1).replace(/^q/, '');
  1052. if (pn.closest('[id^="fcx-ids-"]')) this.jump(id);
  1053. else {
  1054. this.qBox.style.display = 'block';
  1055. this.qBody.value += '>>' + id + '\n';
  1056. this.qBody.focus();
  1057. }
  1058. return;
  1059. }
  1060.  
  1061. const idEl = e.target.closest('.labelId');
  1062. if (idEl) {
  1063. e.preventDefault();
  1064. this.openIdBox(idEl.textContent);
  1065. }
  1066. }, true);
  1067.  
  1068. // 🖼️ Media preloading on hover (image/video)
  1069. this.thread.addEventListener('pointerover', e => {
  1070. const a = e.target.closest('a[data-filemime]');
  1071. if (!a || a._preloaded || a._prev) return;
  1072.  
  1073. const url = a.href;
  1074. const mime = a.dataset.filemime?.toLowerCase() || '';
  1075.  
  1076. if (mime.startsWith('image/')) {
  1077. const img = new Image();
  1078. img.src = url;
  1079. a._preloadImage = img;
  1080. addToCache(url, img);
  1081.  
  1082. img.decode?.().catch(() => {});
  1083. } else {
  1084. fetch(url, { mode: 'no-cors' }).catch(() => {});
  1085. }
  1086.  
  1087. a._preloaded = true;
  1088. }, { passive: true });
  1089.  
  1090. // 🎞️ Media preview logic (on hover)
  1091. this.thread.addEventListener('pointerenter', async e => {
  1092. const anchor = e.target.closest('a[data-filemime]');
  1093. if (!anchor || anchor.dataset._fcxHoverActive === 'true') return;
  1094.  
  1095. anchor.dataset._fcxHoverActive = 'true';
  1096. isHoveringPreview = true;
  1097.  
  1098. this._activePreviewAnchor?.dispatchEvent(new Event('mouseleave'));
  1099. this._activePreviewAnchor = anchor;
  1100.  
  1101. const originalUrl = anchor.href;
  1102. const mime = anchor.dataset.filemime?.toLowerCase() || '';
  1103. if (!OK_MIME.test(mime) && !OK_EXT.test(originalUrl)) return;
  1104. if (originalUrl.includes('catbox.moe') || originalUrl.includes('z.zz.fo')) return;
  1105.  
  1106. const isVideo = mime.startsWith('video/');
  1107. const isAudio = mime.startsWith('audio/');
  1108. const isImg = mime.startsWith('image/');
  1109. let url = originalUrl;
  1110. if ((isVideo || isAudio) && !url.includes('?')) {
  1111. url += `?fcxcb=${Date.now()}`;
  1112. }
  1113.  
  1114. const spinner = document.createElement('div');
  1115. Object.assign(spinner.style, STYLE_SPINNER);
  1116.  
  1117. spinner.textContent = '⏳ Loading ⏳';
  1118. spinner.className = 'fcx-spinner';
  1119.  
  1120. let el;
  1121. let loadTimeout;
  1122. let hoverActive = true;
  1123.  
  1124. const cancelHover = () => {
  1125. hoverActive = false;
  1126. isHoveringPreview = false;
  1127. el?.pause?.();
  1128. el?.remove();
  1129. clearTimeout(loadTimeout);
  1130. anchor.contains(spinner) && spinner.remove();
  1131. delete anchor._prev;
  1132. delete anchor.dataset._fcxHoverActive;
  1133. };
  1134.  
  1135. anchor.addEventListener('mouseleave', cancelHover, { once: true });
  1136. anchor.addEventListener('pointercancel', cancelHover, { once: true });
  1137.  
  1138. if (isImg) {
  1139. el = anchor._preloadImage || new Image();
  1140. el.src = url;
  1141. el.dataset.fcxHover = 'true';
  1142. Object.assign(el.style, STYLE_HOVER_MEDIA);
  1143.  
  1144.  
  1145. el.onload = () => {
  1146. if (!hoverActive) return;
  1147. anchor.contains(spinner) && spinner.remove();
  1148. };
  1149.  
  1150. el.onerror = cancelHover;
  1151.  
  1152. if (!document.body.contains(el)) {
  1153. document.body.appendChild(el);
  1154. }
  1155.  
  1156. loadTimeout = setTimeout(() => {
  1157. if (hoverActive && anchor.matches(':hover')) {
  1158. anchor.appendChild(spinner);
  1159. }
  1160. }, 150);
  1161. return;
  1162. }
  1163.  
  1164. const cached = anchor._preloadMedia;
  1165.  
  1166. if (cached) {
  1167. el = cached;
  1168. el.currentTime = 0;
  1169. el.style.display = 'block';
  1170. } else {
  1171. el = document.createElement(isVideo ? 'video' : 'audio');
  1172. el.src = url;
  1173. el.preload = 'auto';
  1174. el.muted = true;
  1175. document.body.appendChild(el);
  1176. anchor._preloadMedia = el;
  1177. addToCache(url, el);
  1178. }
  1179.  
  1180. Object.assign(el, {
  1181. loop: true,
  1182. volume: this.settings?.volume ?? 0.5,
  1183. muted: (this.settings?.volume ?? 0) === 0,
  1184. });
  1185.  
  1186. el.dataset.fcxHover = 'true';
  1187. Object.assign(el.style, {
  1188. position: 'fixed',
  1189. left: '0px',
  1190. top: '0px',
  1191. maxWidth: '90vw',
  1192. maxHeight: '90vh',
  1193. zIndex: 10000,
  1194. pointerEvents: 'none'
  1195. });
  1196.  
  1197. document.body.appendChild(el);
  1198.  
  1199. let attempt = 0;
  1200. const maxRetries = 3;
  1201.  
  1202. const tryLoad = () => {
  1203. if (!hoverActive) return;
  1204. attempt++;
  1205. const start = performance.now();
  1206.  
  1207. if (!cached) el.load?.();
  1208.  
  1209. const checkReady = setInterval(() => {
  1210. if (!hoverActive) {
  1211. clearInterval(checkReady);
  1212. el.remove();
  1213. anchor.contains(spinner) && spinner.remove();
  1214. return;
  1215. }
  1216.  
  1217. if (el.readyState >= 2) {
  1218. clearInterval(checkReady);
  1219. clearTimeout(loadTimeout);
  1220. anchor.contains(spinner) && spinner.remove();
  1221. el.play?.().catch(() => {});
  1222. anchor._prev = el;
  1223. return;
  1224. }
  1225.  
  1226. if (performance.now() - start > 2500) {
  1227. clearInterval(checkReady);
  1228. el.remove();
  1229. anchor.contains(spinner) && spinner.remove();
  1230. if (attempt < maxRetries && hoverActive) {
  1231. setTimeout(tryLoad, 300);
  1232. }
  1233. }
  1234. }, 100);
  1235. };
  1236.  
  1237. loadTimeout = setTimeout(() => {
  1238. if (hoverActive && anchor.matches(':hover')) {
  1239. anchor.appendChild(spinner);
  1240. }
  1241. }, 150);
  1242.  
  1243. tryLoad();
  1244. }, { capture: true, passive: true });
  1245.  
  1246. // ❌ Stop previews if user scrolls or touches
  1247. ['scroll', 'wheel', 'touchmove'].forEach(ev => {
  1248. window.addEventListener(ev, () => this.stopHoverPreviews(), { passive: true });
  1249. });
  1250. }
  1251.  
  1252. stopHoverPreviews() {
  1253. // Avoid interrupting an active preview
  1254. if (isHoveringPreview) return;
  1255.  
  1256. // Remove all hover-tagged media
  1257. QSA('img, video, audio').forEach(el => {
  1258. if (el.dataset.fcxHover) {
  1259. el.pause?.();
  1260. el.remove();
  1261. }
  1262. });
  1263.  
  1264. // Redundant catch-all for untagged media (extra safety)
  1265. QSA('audio, video').forEach(el => {
  1266. el.pause?.();
  1267. el.remove();
  1268. });
  1269.  
  1270. // Clear anchor link preview tracking
  1271. QSA('a[data-filemime]').forEach(a => {
  1272. if (a._prev) delete a._prev;
  1273. });
  1274.  
  1275. // Remove loading indicators
  1276. QSA('.fcx-spinner').forEach(s => s.remove());
  1277. }
  1278.  
  1279. setupMediaPreloader() {
  1280. const MAX_PRELOAD = this.settings.preloadPosts ?? 5; // Number of posts to preload
  1281. const BUFFER_POSTS = MAX_PRELOAD; // Same size buffer before/after
  1282. const SCROLL_IDLE_DELAY = 200; // Delay before triggering preload
  1283.  
  1284. if (!this.settings.preloadImages) return;
  1285.  
  1286. let lastScrollTop = window.scrollY;
  1287. let lastScrollTime = Date.now();
  1288. let scrollDir = 'down';
  1289. let scrollTimer = null;
  1290. let isScrolling = false;
  1291.  
  1292. // 🔁 Handle actual media preloading
  1293. const preload = (a) => {
  1294. if (a._preloaded || a._prev) return;
  1295. const url = a.href;
  1296. const mime = a.dataset.filemime?.toLowerCase() || '';
  1297.  
  1298. if (mime.startsWith('image/')) {
  1299. const img = new Image();
  1300. img.src = url;
  1301. img.decode?.().catch(() => {});
  1302. } else if (mime.startsWith('video/') || mime.startsWith('audio/')) {
  1303. const media = document.createElement(mime.startsWith('video/') ? 'video' : 'audio');
  1304. media.src = url;
  1305. media.preload = 'auto';
  1306. media.muted = true;
  1307. media.style.display = 'none';
  1308. document.body.appendChild(media);
  1309. media.load();
  1310. a._preloadMedia = media;
  1311. } else {
  1312. fetch(url, { mode: 'no-cors' }).catch(() => {});
  1313. }
  1314.  
  1315. a._preloaded = true;
  1316. };
  1317.  
  1318. // 🧠 Track scroll direction and debounce logic
  1319. const updateScrollState = () => {
  1320. const now = Date.now();
  1321. const top = window.scrollY;
  1322. scrollDir = (top > lastScrollTop) ? 'down' : 'up';
  1323. lastScrollTop = top;
  1324. lastScrollTime = now;
  1325. isScrolling = true;
  1326.  
  1327. if (scrollTimer) clearTimeout(scrollTimer);
  1328. scrollTimer = setTimeout(() => {
  1329. isScrolling = false;
  1330. doSmartPreload();
  1331. }, SCROLL_IDLE_DELAY);
  1332. };
  1333.  
  1334. // 📦 Get posts currently visible in viewport
  1335. const getVisiblePosts = () => {
  1336. const posts = QSA('.postCell, .opCell', this.thread);
  1337. const winTop = window.scrollY;
  1338. const winBottom = winTop + window.innerHeight;
  1339. const result = [];
  1340.  
  1341. for (let i = 0; i < posts.length; i++) {
  1342. const post = posts[i];
  1343. const rect = post.getBoundingClientRect();
  1344. const absTop = rect.top + window.scrollY;
  1345. const absBottom = rect.bottom + window.scrollY;
  1346.  
  1347. if (absBottom > winTop && absTop < winBottom) {
  1348. result.push({ post, index: i });
  1349. }
  1350. }
  1351.  
  1352. return result;
  1353. };
  1354.  
  1355. // 🎯 Smart preload around current visible center post
  1356. const doSmartPreload = () => {
  1357. if (!this.settings.preloadImages) return;
  1358.  
  1359. const posts = QSA('.postCell, .opCell', this.thread);
  1360. const visible = getVisiblePosts();
  1361. if (visible.length === 0) return;
  1362.  
  1363. const centerIndex = visible[Math.floor(visible.length / 2)].index;
  1364.  
  1365. // 👀 Select posts around the current center post
  1366. const buffer = [];
  1367. for (let offset = -BUFFER_POSTS; offset <= BUFFER_POSTS; offset++) {
  1368. const idx = scrollDir === 'up'
  1369. ? centerIndex - offset
  1370. : centerIndex + offset;
  1371. if (posts[idx]) buffer.push(posts[idx]);
  1372. }
  1373.  
  1374. // Combine visible + buffer, remove duplicates
  1375. const postSet = new Set([...visible.map(v => v.post), ...buffer]);
  1376.  
  1377. // Extract media links to preload
  1378. const mediaLinks = [...postSet]
  1379. .flatMap(post => QSA('a[data-filemime]', post).filter(a => !a._preloaded && !a._prev))
  1380. .slice(0, MAX_PRELOAD);
  1381.  
  1382. // Prioritize videos over static images
  1383. mediaLinks.sort((a, b) => {
  1384. const m1 = a.dataset.filemime || '';
  1385. const m2 = b.dataset.filemime || '';
  1386. if (m1.startsWith('video/') && !m2.startsWith('video/')) return -1;
  1387. if (!m1.startsWith('video/') && m2.startsWith('video/')) return 1;
  1388. return 0;
  1389. });
  1390.  
  1391. // Preload with idle priority
  1392. requestIdleCallback(() => {
  1393. mediaLinks.forEach(preload);
  1394. }, { timeout: 100 });
  1395. };
  1396.  
  1397. // 👂 Listeners
  1398. window.addEventListener('scroll', updateScrollState, { passive: true });
  1399. window.addEventListener('resize', doSmartPreload, { passive: true });
  1400.  
  1401. requestIdleCallback(() => doSmartPreload());
  1402. }
  1403.  
  1404. markCrossThreadQuotes(container = document) {
  1405. // Search all quote links within the given container
  1406. QSA('.quoteLink, .quotelink', container).forEach(q => {
  1407. // Skip if already processed
  1408. if (q.dataset.crossThreaded) return;
  1409.  
  1410. // 🧠 Case 1: Explicit format >>12345 (67890)
  1411. const explicitMatch = q.textContent.match(/^>>(\d+)\s+\((\d+)\)$/);
  1412. if (explicitMatch) {
  1413. const [ , postId, threadId ] = explicitMatch;
  1414. // If it's not from the current thread, rewrite the href and tag
  1415. if (threadId !== this.tid) {
  1416. q.href = `/${location.pathname.split('/')[1]}/res/${threadId}.html#${postId}`;
  1417. this._tagAsCrossThread(q);
  1418. }
  1419. return; // No need to proceed further if already matched
  1420. }
  1421.  
  1422. // 🧠 Case 2: Link directly to a post in another thread
  1423. const hrefMatch = q.getAttribute('href')?.match(/\/res\/(\d+)\.html#(\d+)/);
  1424. if (hrefMatch) {
  1425. const [ , threadId, postId ] = hrefMatch;
  1426. if (threadId !== this.tid) {
  1427. this._tagAsCrossThread(q);
  1428. }
  1429. }
  1430. });
  1431. }
  1432.  
  1433. _tagAsCrossThread(q) {
  1434. // 🛑 Prevent duplicate visual tag
  1435. if (q.querySelector('.cross-thread-label')) return;
  1436.  
  1437. // ✍️ Create and style the label
  1438. const label = document.createElement('span');
  1439. label.textContent = '(Cross-Thread)';
  1440. label.className = 'cross-thread-label';
  1441.  
  1442. Object.assign(label.style, {
  1443. fontStyle: 'italic',
  1444. fontSize: '90%',
  1445. color: 'var(--link-color)',
  1446. marginLeft: '4px',
  1447. pointerEvents: 'none' // Prevent accidental clicks
  1448. });
  1449.  
  1450. // 👷 Add label after the quote link
  1451. q.appendChild(label);
  1452. q.dataset.crossThreaded = 'true';
  1453. }
  1454.  
  1455. }
  1456.  
  1457. // Register the custom element so it can be used as <fullchan-x>
  1458. customElements.define('fullchan-x', FullchanX);
  1459.  
  1460. // Utility: Insert <link rel="preconnect"> to improve initial connection time
  1461. const preconnect = url => {
  1462. const link = document.createElement('link');
  1463. link.rel = 'preconnect';
  1464. link.href = url;
  1465. link.crossOrigin = '';
  1466. document.head.appendChild(link);
  1467. };
  1468.  
  1469. // Preconnect to the site's origin for quicker resource loading
  1470. preconnect(location.origin); // Safe: same-origin and dynamically scoped
  1471.  
  1472. // Inject the main component into the page
  1473. document.body.appendChild(document.createElement('fullchan-x'));
  1474. // Inject custom CSS to style optional features and UI panels
  1475. const style = document.createElement('style');
  1476. style.textContent = `
  1477. /* Hide filtered stubs when enabled */
  1478. body.fcx-hide-stubs .postCell:has(.unhideButton.glowOnHover) {
  1479. display: none !important;
  1480. }
  1481.  
  1482. /* Hide user IDs when anonymize mode is enabled */
  1483. body.fcx-anonymize .spanId {
  1484. display: none !important;
  1485. }
  1486.  
  1487. /* Style <input type="number"> fields in settings panel */
  1488. #fcx-settings-panel input[type="number"] {
  1489. background: var(--contrast-color);
  1490. color: var(--link-color);
  1491. border: 1px solid var(--navbar-text-color);
  1492. padding: 4px;
  1493. border-radius: 4px;
  1494. box-sizing: border-box;
  1495. max-width: 80px;
  1496. }
  1497. `;
  1498. document.head.appendChild(style);
  1499.  
Add Comment
Please, Sign In to add comment