Guest User

Untitled

a guest
Apr 18th, 2025
44
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 35.67 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Fullchan‑X (v3.15)
  3. // @namespace violentmonkey‑fullchan‑x
  4. // @match https://8chan.moe/*/res/*.html*
  5. // @match https://8chan.se/*/res/*.html*
  6. // @version 3.15‑2025‑04‑17
  7. // @description Recursive inlined quotes, single posts-by-ID window, (You)s window, media previews, hide stubs, anonymize (hide IDs), preloading
  8. // @author vfyxe → grok → ChatGPT
  9. // @grant none
  10. // ==/UserScript==
  11.  
  12. if (!document.querySelector('#divThreads')) return;
  13.  
  14. const QS = (s, r = document) => r.querySelector(s);
  15. const QSA = (s, r = document) => [...r.querySelectorAll(s)];
  16. const idNorm = s => (s.match(/^[0-9a-f]{6}/i) || [''])[0].toLowerCase();
  17. const HOST = location.hostname;
  18. const OK_MIME = /^(video\/(mp4|webm)|image\/(png|jpe?g|gif)|audio\/mpeg)$/i;
  19. const OK_EXT = /\.(mp4|webm|png|jpe?g|gif|mp3)$/i;
  20. const PANEL_SEL = '#fcx-yous, [id^="fcx-ids-"]';
  21.  
  22. function drag(el, grip = el) {
  23. let sx, sy, ox, oy, moved = false;
  24. grip.style.cursor = 'move';
  25. grip.onmousedown = e => {
  26. sx = e.clientX; sy = e.clientY;
  27. ({ left: ox, top: oy } = el.getBoundingClientRect());
  28. const move = ev => {
  29. const dx = ev.clientX - sx, dy = ev.clientY - sy;
  30. if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
  31. el.style.left = ox + dx + 'px';
  32. el.style.top = oy + dy + 'px';
  33. };
  34. const up = () => {
  35. window.removeEventListener('mousemove', move);
  36. if (!moved) grip.dispatchEvent(new CustomEvent('tap'));
  37. moved = false;
  38. };
  39. window.addEventListener('mousemove', move);
  40. window.addEventListener('mouseup', up, { once: true });
  41. };
  42. }
  43.  
  44. function fit(box) {
  45. const wrap = box.querySelector('.fcx-id-posts');
  46. if (!wrap) return;
  47.  
  48. // Temporarily force-wrap to shrink to content
  49. wrap.style.display = 'inline-block';
  50. wrap.style.flexDirection = 'column';
  51. wrap.style.alignItems = 'start';
  52. wrap.style.maxWidth = 'none';
  53.  
  54. requestAnimationFrame(() => {
  55. const width = Math.min(wrap.offsetWidth + 32, window.innerWidth * 0.9);
  56. const height = wrap.scrollHeight + 64;
  57.  
  58. box.style.width = width + 'px';
  59. box.style.height = height + 'px';
  60.  
  61. // Restore flex layout after measurement
  62. wrap.style.display = 'flex';
  63. wrap.style.maxWidth = '';
  64. });
  65. }
  66.  
  67.  
  68.  
  69. class FullchanX extends HTMLElement {
  70. quoteMap = new WeakMap();
  71.  
  72.  
  73. indexIds() {
  74. QSA('.opCell, .postCell, .post').forEach(p => {
  75. const l = p.querySelector('.labelId');
  76. if (!l) return;
  77. const id = idNorm(l.textContent);
  78. if (!this.idMap.has(id)) this.idMap.set(id, []);
  79. this.idMap.get(id).push(p);
  80. });
  81. }
  82.  
  83. connectedCallback() {
  84. this.thread = QS('.divPosts');
  85. this.qBox = QS('#quick-reply');
  86. this.qBody = QS('#qrbody');
  87. this.tid = QS('.opCell')?.id || 'thread';
  88. this.seenKey = `${this.tid}-seen-yous`;
  89. this.seen = JSON.parse(localStorage.getItem(this.seenKey) || '[]');
  90. this.idMap = new Map();
  91.  
  92. this.indexIds();
  93. this.observeIds();
  94. this.markCrossThreadQuotes();
  95. this.createSettingsIcon();
  96.  
  97. this.youBox = this.panel('fcx-yous', 'My (You)s', { right: '1rem', top: '3rem' });
  98. this.loadSettings();
  99. this.setupMediaPreloader();
  100.  
  101. this.refreshYous();
  102.  
  103. this.bindGlobal();
  104. document.addEventListener('visibilitychange', () => {
  105. if (document.hidden) {
  106. QSA('audio, video').forEach(el => {
  107. try { el.pause(); } catch {}
  108. });
  109. }
  110. });
  111.  
  112. // Efficiently observe new posts with throttling
  113. let throttleTimer = null;
  114. const scheduleUpdate = () => {
  115. if (throttleTimer) cancelIdleCallback(throttleTimer);
  116. throttleTimer = requestIdleCallback(() => this.refreshYous(), { timeout: 200 });
  117. };
  118.  
  119. new MutationObserver(scheduleUpdate).observe(this.thread, {
  120. childList: true,
  121. subtree: false
  122. });
  123. }
  124.  
  125. disconnectedCallback() {
  126. if (this.io) this.io.disconnect?.();
  127. }
  128.  
  129. observeIds() {
  130. const container = QS('.divPosts');
  131. if (!container) return;
  132.  
  133. const handleNewPost = node => {
  134. if (!(node instanceof HTMLElement)) return;
  135.  
  136. const postElement = node.matches('.opCell, .postCell, .post')
  137. ? node
  138. : node.querySelector('.opCell, .postCell, .post');
  139.  
  140. if (!postElement) return;
  141.  
  142. const label = postElement.querySelector('.labelId');
  143. if (!label) return;
  144.  
  145. const id = idNorm(label.textContent);
  146. if (!this.idMap.has(id)) {
  147. this.idMap.set(id, []);
  148. }
  149.  
  150. this.idMap.get(id).push(postElement);
  151.  
  152. // Mark any cross-thread quotes in this new post
  153. this.markCrossThreadQuotes(postElement);
  154. };
  155.  
  156. new MutationObserver(mutations => {
  157. for (const mutation of mutations) {
  158. for (const node of mutation.addedNodes) {
  159. handleNewPost(node);
  160. }
  161. }
  162. }).observe(container, {
  163. childList: true,
  164. subtree: false
  165. });
  166. }
  167.  
  168.  
  169. panel(id, title, pos) {
  170. const box = Object.assign(document.createElement('div'), { id });
  171. box.innerHTML = `
  172. <div class="hdr" style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
  173. <span class="hdrText">${title}</span>
  174. <div class="fcx-nav" style="display: flex; gap: 4px;"></div>
  175. </div>
  176. <div class="lst"></div>
  177. `;
  178.  
  179.  
  180. Object.assign(box.style, {
  181. position: 'fixed', background: 'var(--contrast-color)',
  182. color: 'var(--link-color)', border: '1px solid var(--navbar-text-color)',
  183. padding: '6px', minWidth: '160px', maxWidth: '90vw', zIndex: 9999, transition: 'width 0.2s ease, height 0.2s ease',
  184. font: '14px/1 sans-serif', ...pos
  185. });
  186. const hdr = box.querySelector('.hdr'),
  187. text = box.querySelector('.hdrText'),
  188. lst = box.querySelector('.lst');
  189. Object.assign(lst.style, {
  190. lineHeight: '1.5',
  191. display: 'block',
  192. minWidth: '100%',
  193. whiteSpace: 'normal',
  194. overflowX: 'hidden',
  195. overflowY: 'auto'
  196. });
  197.  
  198. requestAnimationFrame(() => box.dataset.baseWidth = box.offsetWidth);
  199. new MutationObserver(() => fit(box)).observe(lst, { childList: true });
  200. document.body.appendChild(box);
  201. drag(box, hdr);
  202.  
  203. if (id.startsWith('fcx-ids-')) text.onclick = () => box.remove();
  204. else if (id === 'fcx-yous') text.onclick = () => this.toggleYou(box);
  205. return box;
  206. }
  207.  
  208. toggleYou(box) {
  209. const lst = box.querySelector('.lst');
  210. if (lst.style.display === 'none') {
  211. lst.style.display = 'block';
  212. fit(box);
  213. } else {
  214. lst.style.display = 'none';
  215. box.style.width = box.style.height = 'auto';
  216. }
  217. }
  218.  
  219. pid(post) {
  220. const a = QSA('a', post).find(x => /^\d+$/.test(x.textContent.trim()));
  221. return a ? a.textContent.trim() : (post.id || '').replace(/^p/, '');
  222. }
  223.  
  224. jump(id) {
  225. const p = document.getElementById(id);
  226. if (!p) return;
  227. p.scrollIntoView({ behavior: 'smooth', block: 'center' });
  228. p.style.outline = '2px solid var(--link-color)';
  229. setTimeout(() => p.style.outline = '', 1500);
  230. }
  231.  
  232. refreshYous() {
  233. const lst = this.youBox.querySelector('.lst');
  234. const seenIds = new Set(this.seen);
  235. const existing = new Set([...lst.querySelectorAll('a')].map(a => a.textContent));
  236.  
  237. const frag = document.createDocumentFragment();
  238.  
  239. QSA('.quoteLink.you, .quotelink.you', this.thread).forEach(q => {
  240. const post = q.closest('.postCell, .post');
  241. if (!post) return;
  242. const id = post.id;
  243. const ref = '>>' + id;
  244.  
  245. if (!existing.has(ref)) {
  246. const a = document.createElement('a');
  247. a.textContent = ref;
  248. a.href = '#' + id;
  249. a.style.display = 'block';
  250. a.style.fontWeight = seenIds.has(id) ? 'normal' : '700';
  251. a.onclick = e => { e.preventDefault(); this.jump(id); };
  252. frag.appendChild(a);
  253. }
  254. });
  255.  
  256. lst.appendChild(frag);
  257.  
  258. if (!this.io) {
  259. this.io = new IntersectionObserver(ents => ents.forEach(ent => {
  260. if (ent.isIntersecting) {
  261. const id = ent.target.id;
  262. if (!seenIds.has(id)) {
  263. this.seen.push(id);
  264. localStorage.setItem(this.seenKey, JSON.stringify(this.seen));
  265. this.io.unobserve(ent.target);
  266. this.refreshYous();
  267. }
  268. }
  269. }), { threshold: .15 });
  270. }
  271.  
  272. this.io.disconnect();
  273. QSA('.postCell, .post', this.thread)
  274. .filter(p => !seenIds.has(p.id))
  275. .slice(0, 200)
  276. .forEach(p => this.io.observe(p));
  277. }
  278.  
  279. openIdBox(raw) {
  280. const id = idNorm(raw);
  281. const boxId = `fcx-ids-${id}`;
  282. const prevOpen = QS('[id^="fcx-ids-"]');
  283.  
  284. if (prevOpen && prevOpen.id !== boxId) prevOpen.remove();
  285. else if (prevOpen) { prevOpen.remove(); return; }
  286.  
  287. const posts = (this.idMap.get(id) || []).slice(0, 10);
  288. const box = this.panel(boxId, `Posts by ID: ${id}`, { right: '1rem', top: '12rem' });
  289. box.classList.add('fcx-idview'); // mark as ID view panel
  290.  
  291. // 🧠 Make header a flex row with title + arrows
  292. const hdr = box.querySelector('.hdr');
  293. hdr.style.display = 'flex';
  294. hdr.style.alignItems = 'center';
  295. hdr.style.justifyContent = 'space-between';
  296. hdr.style.gap = '8px';
  297.  
  298. // Create title + nav container
  299. const title = box.querySelector('.hdrText');
  300. const nav = document.createElement('div');
  301. nav.className = 'fcx-nav';
  302. Object.assign(nav.style, {
  303. display: 'flex',
  304. gap: '4px'
  305. });
  306.  
  307. nav.innerHTML = `
  308. <button style="width:2em;">◀</button>
  309. <button style="width:2em;">▶</button>
  310. `;
  311.  
  312. hdr.appendChild(nav);
  313. const [prev, next] = nav.querySelectorAll('button');
  314.  
  315. const lst = box.querySelector('.lst');
  316. const wrap = document.createElement('div');
  317. Object.assign(wrap.style, {
  318. display: 'flex',
  319. flexDirection: 'column',
  320. gap: '8px',
  321. alignItems: 'stretch',
  322. });
  323. wrap.className = 'fcx-id-posts';
  324.  
  325. lst.appendChild(wrap);
  326.  
  327. const clones = posts.map(post => {
  328. const num = this.pid(post);
  329. const body = post.querySelector('.innerOP, .innerPost, .postMessage');
  330. if (!num || !body) return null;
  331.  
  332. const container = document.createElement('div');
  333. Object.assign(container.style, {
  334. border: '1px solid var(--navbar-text-color)',
  335. padding: '6px',
  336. borderRadius: '4px',
  337. backgroundColor: 'rgba(255,255,255,0.02)',
  338. boxSizing: 'border-box',
  339. overflow: 'auto',
  340. overflowWrap: 'break-word',
  341. maxHeight: '60vh'
  342. });
  343.  
  344. const content = body.cloneNode(true);
  345. container.appendChild(content);
  346.  
  347. QSA('a.quoteLink, a.quotelink', container).forEach(a => {
  348. if (!a.textContent.startsWith('>>')) return;
  349. a.onclick = e => {
  350. e.preventDefault();
  351. const match = a.href.match(/#q?(\d+)/);
  352. if (match) {
  353. this.jump(match[1]);
  354. }
  355. };
  356. });
  357.  
  358. return container;
  359. }).filter(Boolean);
  360.  
  361. let idx = 0;
  362. const show = i => {
  363. idx = (i + clones.length) % clones.length;
  364.  
  365. // Hide wrap and clear existing content
  366. wrap.style.display = 'none';
  367. wrap.innerHTML = '';
  368.  
  369. // Reset box size
  370. box.style.width = '';
  371. box.style.height = '';
  372.  
  373. // Add new post(s)
  374. wrap.appendChild(clones[idx]);
  375. if (clones.length > 1) {
  376. wrap.appendChild(clones[(idx + 1) % clones.length]);
  377. }
  378.  
  379. // Re-show and refit
  380. requestAnimationFrame(() => {
  381. wrap.style.display = 'flex';
  382. requestAnimationFrame(() => fit(box));
  383. });
  384. };
  385.  
  386. prev.onclick = () => show(idx - 2);
  387. next.onclick = () => show(idx + 2);
  388.  
  389. box.tabIndex = 0;
  390. box.onkeydown = e => {
  391. if (e.key === 'ArrowLeft') { e.preventDefault(); show(idx - 2); }
  392. if (e.key === 'ArrowRight') { e.preventDefault(); show(idx + 2); }
  393. };
  394.  
  395. show(0);
  396. box.focus();
  397. }
  398.  
  399. toggleQuote(pid, link, small = false) {
  400. if (this.quoteMap.has(link)) {
  401. const existing = this.quoteMap.get(link);
  402. existing.remove();
  403. this.quoteMap.delete(link);
  404. return;
  405. }
  406.  
  407. const src = document.getElementById(pid) || document.getElementById('p' + pid);
  408. if (!src) return;
  409.  
  410. const body = src.querySelector('.innerOP, .innerPost, .postMessage');
  411. if (!body) return;
  412.  
  413. const inPanel = !!link.closest(PANEL_SEL);
  414. const depth = inPanel
  415. ? +(link.dataset.depth || 0)
  416. : +(link.closest('.nestedPost')?.dataset.depth || 0);
  417.  
  418. const wrap = document.createElement('div');
  419. wrap.dataset.depth = depth + 1;
  420. wrap.className = 'nestedPost';
  421. Object.assign(wrap.style, {
  422. border: '1px solid var(--navbar-text-color)',
  423. margin: `.3em 0 .3em ${(depth + 1) * 20}px`,
  424. padding: '4px',
  425. width: '100%',
  426. boxSizing: 'border-box',
  427. overflow: 'visible',
  428. display: 'block',
  429. fontSize: small ? '90%' : ''
  430. });
  431.  
  432. // Use cloneNode for safe copying
  433. wrap.appendChild(body.cloneNode(true));
  434. this.quoteMap.set(link, wrap);
  435. link.dataset.depth = depth + 1;
  436.  
  437. const parentPost = link.closest('.nestedPost') || link.closest('.postCell, .post');
  438. if (parentPost) {
  439. parentPost.appendChild(wrap);
  440. } else {
  441. link.after(wrap);
  442. }
  443.  
  444. if (inPanel) fit(link.closest(PANEL_SEL));
  445. }
  446.  
  447. createSettingsIcon() {
  448. const icon = document.createElement('div');
  449. icon.id = 'fcx-settings-icon';
  450. icon.innerHTML = '⚙️';
  451. Object.assign(icon.style, {
  452. position: 'fixed',
  453. bottom: '1rem',
  454. right: '1rem',
  455. fontSize: '20px',
  456. cursor: 'pointer',
  457. zIndex: 10000
  458. });
  459. icon.onclick = () => this.toggleSettingsPanel();
  460. document.body.appendChild(icon);
  461. }
  462.  
  463. toggleSettingsPanel() {
  464. const existing = document.getElementById('fcx-settings-panel');
  465. if (existing) {
  466. existing.remove();
  467. return;
  468. }
  469. this.createSettingsPanel();
  470. }
  471.  
  472. createSettingsPanel() {
  473. const panel = document.createElement('div');
  474. panel.id = 'fcx-settings-panel';
  475. Object.assign(panel.style, {
  476. position: 'fixed',
  477. bottom: '3rem',
  478. right: '1rem',
  479. background: 'var(--contrast-color)',
  480. color: 'var(--link-color)',
  481. border: '1px solid var(--navbar-text-color)',
  482. padding: '10px',
  483. borderRadius: '6px',
  484. zIndex: 10000,
  485. font: '14px sans-serif',
  486. minWidth: '220px'
  487. });
  488.  
  489. panel.innerHTML = `
  490. <label style="display:block; margin-bottom:8px;">
  491. <input type="checkbox" id="fcx-toggle-yous">
  492. Show My (You)s
  493. </label>
  494.  
  495. <label style="display:block; margin-bottom:8px;">
  496. Media Volume:
  497. <input type="range" id="fcx-volume-slider" min="0" max="100" style="width: 100%;">
  498. </label>
  499.  
  500. <label style="display:block; margin-bottom:8px;">
  501. <input type="checkbox" id="fcx-hide-stubs">
  502. Hide Filtered Stubs
  503. </label>
  504.  
  505. <label style="display:block; margin-bottom:8px;">
  506. <input type="checkbox" id="fcx-anonymize">
  507. Anonymize (hide IDs)
  508. </label>
  509. <label style="display:block; margin-bottom:8px;">
  510. <input type="checkbox" id="fcx-preload-images">
  511. Preload Images
  512. </label>
  513.  
  514. <label style="display:block; margin-bottom:8px;">
  515. Max Posts to Preload:
  516. <input type="number" id="fcx-preload-posts" min="1" max="50" value="5" style="width: 100%;">
  517. </label>
  518.  
  519. <button id="fcx-stop-previews" style="margin-top:10px; width: 100%;">⏹️ Stop All Previews</button>
  520. `;
  521.  
  522.  
  523. document.body.appendChild(panel);
  524.  
  525. // Set current state
  526. QS('#fcx-toggle-yous').checked = this.settings.showYous;
  527. QS('#fcx-volume-slider').value = Math.round(this.settings.volume * 100);
  528. QS('#fcx-hide-stubs').checked = this.settings.hideStubs;
  529. QS('#fcx-anonymize').checked = this.settings.anonymize;
  530. QS('#fcx-stop-previews').onclick = () => {
  531. this.stopAllPreviews();
  532. };
  533.  
  534. // Set current state
  535. QS('#fcx-preload-images').checked = this.settings.preloadImages;
  536. QS('#fcx-preload-posts').value = this.settings.preloadPosts;
  537.  
  538. QS('#fcx-preload-images').onchange = e => {
  539. this.settings.preloadImages = e.target.checked;
  540. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  541. };
  542.  
  543. QS('#fcx-preload-posts').oninput = e => {
  544. this.settings.preloadPosts = Math.max(1, Math.min(50, parseInt(e.target.value) || 5));
  545. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  546. };
  547.  
  548.  
  549. QS('#fcx-anonymize').onchange = e => {
  550. this.settings.anonymize = e.target.checked;
  551. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  552. document.body.classList.toggle('fcx-anonymize', this.settings.anonymize);
  553. };
  554.  
  555.  
  556. // Event listeners
  557. QS('#fcx-toggle-yous').onchange = e => {
  558. this.settings.showYous = e.target.checked;
  559. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  560. this.youBox.style.display = this.settings.showYous ? 'block' : 'none';
  561. fit(this.youBox);
  562. };
  563.  
  564. QS('#fcx-volume-slider').oninput = e => {
  565. this.settings.volume = +e.target.value / 100;
  566. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  567. };
  568.  
  569. QS('#fcx-hide-stubs').onchange = e => {
  570. this.settings.hideStubs = e.target.checked;
  571. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  572. document.body.classList.toggle('fcx-hide-stubs', this.settings.hideStubs);
  573. };
  574. }
  575.  
  576. loadSettings() {
  577. const saved = JSON.parse(localStorage.getItem('fcx-settings') || '{}');
  578. this.settings = {
  579. showYous: saved.showYous ?? true,
  580. volume: saved.volume ?? 0.5,
  581. hideStubs: saved.hideStubs ?? false,
  582. anonymize: saved.anonymize ?? false,
  583. preloadImages: saved.preloadImages ?? true,
  584. preloadPosts: saved.preloadPosts ?? 5
  585. };
  586.  
  587.  
  588. if (!this.settings.showYous) {
  589. this.youBox.style.display = 'none';
  590. }
  591. if (this.settings.hideStubs) {
  592. document.body.classList.add('fcx-hide-stubs');
  593. }
  594. if (this.settings.anonymize) {
  595. document.body.classList.add('fcx-anonymize');
  596. }
  597.  
  598. }
  599.  
  600. stopAllPreviews() {
  601. // Aggressively pause and remove all preview audio and video
  602. QSA('audio, video').forEach(el => {
  603. try {
  604. el.pause();
  605. el.currentTime = 0;
  606. } catch (err) {}
  607. el.remove();
  608. });
  609.  
  610. // Remove image previews (especially floating ones)
  611. QSA('img').forEach(el => {
  612. const style = getComputedStyle(el);
  613. if (style.position === 'fixed' || el.dataset.fcxHover || el.style.pointerEvents === 'none') {
  614. el.remove();
  615. }
  616. });
  617.  
  618. // Remove loading spinners
  619. QSA('.fcx-spinner').forEach(spin => spin.remove());
  620.  
  621. // Reset preview tracking
  622. QSA('a[data-filemime]').forEach(a => {
  623. delete a._prev;
  624. delete a._preloaded;
  625. });
  626. }
  627.  
  628. bindGlobal() {
  629. // Quote click inside thread
  630. this.thread.addEventListener('click', e => {
  631. const link = e.target.closest('a.quoteLink, a.quotelink');
  632. if (!link || !link.textContent.startsWith('>>')) return;
  633.  
  634. const match = link.href.match(/#q?(\d+)/);
  635. if (!match) return;
  636.  
  637. e.preventDefault();
  638. const quoteId = match[1];
  639. const inPanel = link.closest('[id^="fcx-ids-"]');
  640.  
  641. if (inPanel) {
  642. this.jump(quoteId);
  643. } else {
  644. this.toggleQuote(quoteId, link);
  645. }
  646. }, true);
  647.  
  648.  
  649. // Global post number and ID handler
  650. document.addEventListener('click', e => {
  651. const pn = e.target.closest('a.postNum, a.post_no, a.no, a.linkQuote');
  652. if (pn) {
  653. e.preventDefault();
  654. e.stopPropagation();
  655. e.stopImmediatePropagation();
  656. const id = pn.hash.slice(1).replace(/^q/, '');
  657. if (pn.closest('[id^="fcx-ids-"]')) this.jump(id);
  658. else {
  659. this.qBox.style.display = 'block';
  660. this.qBody.value += '>>' + id + '\n';
  661. this.qBody.focus();
  662. }
  663. return;
  664. }
  665.  
  666. const idEl = e.target.closest('.labelId');
  667. if (idEl) {
  668. e.preventDefault();
  669. this.openIdBox(idEl.textContent);
  670. }
  671. }, true);
  672.  
  673. // Media preloading on pointerover
  674. this.thread.addEventListener('pointerover', e => {
  675. const a = e.target.closest('a[data-filemime]');
  676. if (!a || a._preloaded || a._prev) return;
  677.  
  678. const url = a.href;
  679. const mime = a.dataset.filemime?.toLowerCase() || '';
  680.  
  681. if (mime.startsWith('image/')) {
  682. const img = new Image();
  683. img.src = url;
  684. img.decode?.().then(() => {
  685. // Optional: console.log("Preloaded image:", url);
  686. }).catch(() => {});
  687. } else {
  688. fetch(url, { mode: 'no-cors' }).catch(() => {});
  689. }
  690.  
  691. a._preloaded = true;
  692. }, { passive: true });
  693.  
  694. // Media preview on hover
  695. this.thread.addEventListener('pointerenter', async e => {
  696. const a = e.target.closest('a[data-filemime]');
  697. if (!a) return;
  698.  
  699. this._activePreviewAnchor?.dispatchEvent(new Event('mouseleave'));
  700. this._activePreviewAnchor = a;
  701.  
  702. if (a._prev) {
  703. a._prev.pause?.();
  704. a._prev.remove();
  705. delete a._prev;
  706. }
  707.  
  708. const originalUrl = a.href;
  709. const mime = a.dataset.filemime?.toLowerCase() || '';
  710.  
  711. // Fix 2: cache-busting for Chrome reliability
  712. let url = originalUrl;
  713. if (!mime.startsWith('image/')) {
  714. const bust = `fcxcb=${Date.now()}`;
  715. url = originalUrl.includes('?') ? `${originalUrl}&${bust}` : `${originalUrl}?${bust}`;
  716. }
  717.  
  718.  
  719. if (!OK_MIME.test(mime) && !OK_EXT.test(url)) return;
  720. if (url.includes('catbox.moe') || url.includes('z.zz.fo')) return;
  721.  
  722. let el;
  723. const isVideo = mime.startsWith('video/');
  724. const isAudio = mime.startsWith('audio/');
  725. const isImg = mime.startsWith('image/');
  726.  
  727. if (isVideo || isAudio) {
  728. el = document.createElement(isVideo ? 'video' : 'audio');
  729. Object.assign(el, {
  730. src: url,
  731. loop: true,
  732. preload: 'auto',
  733. volume: this.settings?.volume ?? 0.5
  734. });
  735. } else if (isImg) {
  736. el = new Image();
  737. el.loading = 'eager';
  738. el.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
  739. el.src = url;
  740. }
  741.  
  742. el.dataset.fcxHover = 'true';
  743. Object.assign(el.style, {
  744. position: 'fixed',
  745. left: '0px',
  746. top: '0px',
  747. maxWidth: '90vw',
  748. maxHeight: '90vh',
  749. zIndex: 10000,
  750. pointerEvents: 'none'
  751. });
  752.  
  753. // Fix 3: append first before decode
  754. document.body.appendChild(el);
  755.  
  756. try {
  757. if (isImg && el.decode) {
  758. el.onload = () => {
  759. if (a.contains(spinner)) spinner.remove();
  760. };
  761. el.onerror = () => {
  762. if (a.contains(spinner)) spinner.remove();
  763. el.remove();
  764. };
  765.  
  766. if (a.contains(spinner)) spinner.remove();
  767. }
  768. } catch (err) {
  769. console.warn('Image decode failed', err);
  770. }
  771.  
  772. // Show a loading spinner (after decode try, but still within hoverActive check)
  773. const spinner = document.createElement('div');
  774. Object.assign(spinner.style, {
  775. position: 'absolute',
  776. top: '0',
  777. left: '0',
  778. zIndex: 10000,
  779. fontSize: '14px',
  780. color: 'white',
  781. background: 'rgba(0,0,0,0.6)',
  782. padding: '2px 6px',
  783. borderRadius: '4px',
  784. pointerEvents: 'none'
  785. });
  786.  
  787. spinner.textContent = '⏳ Loading ⏳';
  788. spinner.className = 'fcx-spinner';
  789.  
  790. let hoverActive = true;
  791.  
  792. const loadTimeout = setTimeout(() => {
  793. if (!a._prev && hoverActive) {
  794. if (!a.matches(':hover')) return;
  795. a.appendChild(spinner);
  796.  
  797. }
  798. }, 150);
  799.  
  800. const cancelHover = () => {
  801. hoverActive = false;
  802. el.pause?.();
  803. el.remove();
  804. delete a._prev;
  805. clearTimeout(loadTimeout);
  806. if (a.contains(spinner)) spinner.remove();
  807. };
  808.  
  809. ['mouseleave', 'pointercancel', 'pointerout'].forEach(ev => {
  810. a.addEventListener(ev, cancelHover, { once: true });
  811. el.addEventListener(ev, cancelHover, { once: true });
  812. });
  813.  
  814. a._hovering = true;
  815.  
  816. // Retry mechanism for video/audio
  817. const maxRetries = 3;
  818. let attempt = 0;
  819.  
  820. const tryLoad = () => {
  821. if (!hoverActive) return;
  822.  
  823. attempt++;
  824. const start = performance.now();
  825.  
  826. el.load?.();
  827.  
  828. const checkReady = setInterval(() => {
  829. if (!hoverActive) {
  830. clearInterval(checkReady);
  831. el.remove();
  832. if (a.contains(spinner)) spinner.remove();
  833. return;
  834. }
  835.  
  836. if (el.readyState >= 2) {
  837. clearInterval(checkReady);
  838. clearTimeout(loadTimeout);
  839. if (a.contains(spinner)) spinner.remove();
  840. el.play?.().catch(() => {});
  841. a._prev = el;
  842. return;
  843. }
  844.  
  845. if (performance.now() - start > 2500) {
  846. clearInterval(checkReady);
  847. el.remove();
  848. if (a.contains(spinner)) spinner.remove();
  849.  
  850. if (attempt < maxRetries && hoverActive) {
  851. setTimeout(tryLoad, 300);
  852. }
  853. return;
  854. }
  855. }, 100);
  856. };
  857.  
  858. tryLoad();
  859. }, { capture: true, passive: true });
  860.  
  861. ['scroll', 'wheel', 'touchmove'].forEach(ev => {
  862. window.addEventListener(ev, () => this.stopHoverPreviews(), { passive: true });
  863. });
  864.  
  865.  
  866. }
  867.  
  868. stopHoverPreviews() {
  869. QSA('img, video, audio').forEach(el => {
  870. if (el.dataset.fcxHover) {
  871. el.pause?.();
  872. el.remove();
  873. }
  874. });
  875.  
  876. // Also catch unmarked audio elements still lingering
  877. QSA('audio, video').forEach(el => {
  878. el.pause?.();
  879. el.remove();
  880. });
  881.  
  882.  
  883. QSA('a[data-filemime]').forEach(a => {
  884. if (a._prev) delete a._prev;
  885. });
  886.  
  887. QSA('.fcx-spinner').forEach(s => s.remove());
  888.  
  889. }
  890.  
  891.  
  892. setupMediaPreloader() {
  893. const MAX_PRELOAD = this.settings.preloadPosts ?? 5;
  894. const BUFFER_POSTS = MAX_PRELOAD;
  895. const SCROLL_IDLE_DELAY = 200;
  896.  
  897. if (!this.settings.preloadImages) return;
  898.  
  899. let lastScrollTop = window.scrollY;
  900. let lastScrollTime = Date.now();
  901. let scrollDir = 'down';
  902. let scrollTimer = null;
  903. let isScrolling = false;
  904.  
  905. const preload = (a) => {
  906. if (a._preloaded || a._prev) return;
  907. const url = a.href;
  908. const mime = a.dataset.filemime?.toLowerCase() || '';
  909.  
  910. if (mime.startsWith('image/')) {
  911. const img = new Image();
  912. img.src = url;
  913. img.decode?.().catch(() => {});
  914. } else {
  915. fetch(url, { mode: 'no-cors' }).catch(() => {});
  916. }
  917.  
  918. a._preloaded = true;
  919. };
  920.  
  921.  
  922. const updateScrollState = () => {
  923. const now = Date.now();
  924. const top = window.scrollY;
  925. scrollDir = (top > lastScrollTop) ? 'down' : 'up';
  926. lastScrollTop = top;
  927. lastScrollTime = now;
  928. isScrolling = true;
  929.  
  930. if (scrollTimer) clearTimeout(scrollTimer);
  931. scrollTimer = setTimeout(() => {
  932. isScrolling = false;
  933. doSmartPreload();
  934. }, SCROLL_IDLE_DELAY);
  935. };
  936.  
  937. const getVisiblePosts = () => {
  938. const posts = QSA('.postCell, .opCell', this.thread);
  939. const winTop = window.scrollY;
  940. const winBottom = winTop + window.innerHeight;
  941. const result = [];
  942.  
  943. for (let i = 0; i < posts.length; i++) {
  944. const post = posts[i];
  945. const rect = post.getBoundingClientRect();
  946. const absTop = rect.top + window.scrollY;
  947. const absBottom = rect.bottom + window.scrollY;
  948.  
  949. if (absBottom > winTop && absTop < winBottom) {
  950. result.push({ post, index: i });
  951. }
  952. }
  953.  
  954. return result;
  955. };
  956.  
  957. const doSmartPreload = () => {
  958. if (!this.settings.preloadImages) return;
  959.  
  960. const posts = QSA('.postCell, .opCell', this.thread);
  961. const visible = getVisiblePosts();
  962. if (visible.length === 0) return;
  963.  
  964. const centerIndex = visible[Math.floor(visible.length / 2)].index;
  965.  
  966. const range = [];
  967. for (let offset = -BUFFER_POSTS; offset <= BUFFER_POSTS; offset++) {
  968. const idx = scrollDir === 'up'
  969. ? centerIndex - offset
  970. : centerIndex + offset;
  971. if (posts[idx]) range.push(posts[idx]);
  972. }
  973.  
  974. const mediaLinks = range.flatMap(post =>
  975. QSA('a[data-filemime]', post).filter(a => !a._preloaded && !a._prev)
  976. ).slice(0, MAX_PRELOAD);
  977.  
  978. requestIdleCallback(() => {
  979. mediaLinks.forEach(preload);
  980. });
  981.  
  982. };
  983.  
  984. window.addEventListener('scroll', updateScrollState, { passive: true });
  985. window.addEventListener('resize', doSmartPreload, { passive: true });
  986.  
  987. requestIdleCallback(() => doSmartPreload());
  988. }
  989.  
  990.  
  991. markCrossThreadQuotes(container = document) {
  992. QSA('.quoteLink, .quotelink', container).forEach(q => {
  993. if (q.dataset.crossThreaded) return;
  994.  
  995. // Case 1: >>12345 (67890)
  996. const explicitMatch = q.textContent.match(/^>>(\d+)\s+\((\d+)\)$/);
  997. if (explicitMatch) {
  998. const [ , postId, threadId ] = explicitMatch;
  999. if (threadId !== this.tid) {
  1000. q.href = `/${location.pathname.split('/')[1]}/res/${threadId}.html#${postId}`;
  1001. this._tagAsCrossThread(q);
  1002. }
  1003. return;
  1004. }
  1005.  
  1006. // Case 2: Link to a post in a different thread (likely OP)
  1007. const hrefMatch = q.getAttribute('href')?.match(/\/res\/(\d+)\.html#(\d+)/);
  1008. if (hrefMatch) {
  1009. const [ , threadId, postId ] = hrefMatch;
  1010. if (threadId !== this.tid) {
  1011. this._tagAsCrossThread(q);
  1012. }
  1013. }
  1014. });
  1015. }
  1016.  
  1017. _tagAsCrossThread(q) {
  1018. // Make sure href is set properly (already handled by caller)
  1019.  
  1020. // Prevent duplicate label
  1021. if (q.querySelector('.cross-thread-label')) return;
  1022.  
  1023. // Add the visual (Cross-Thread) indicator
  1024. const label = document.createElement('span');
  1025. label.textContent = '(Cross-Thread)';
  1026. label.className = 'cross-thread-label';
  1027. label.style.fontStyle = 'italic';
  1028. label.style.fontSize = '90%';
  1029. label.style.color = 'var(--link-color)';
  1030. label.style.marginLeft = '4px';
  1031. label.style.pointerEvents = 'none';
  1032. q.appendChild(label);
  1033.  
  1034. q.dataset.crossThreaded = 'true';
  1035. }
  1036.  
  1037.  
  1038.  
  1039. }
  1040.  
  1041. customElements.define('fullchan-x', FullchanX);
  1042.  
  1043. const preconnect = url => {
  1044. const link = document.createElement('link');
  1045. link.rel = 'preconnect';
  1046. link.href = url;
  1047. link.crossOrigin = '';
  1048. document.head.appendChild(link);
  1049. };
  1050.  
  1051. preconnect(location.origin); // safe since HOST is always same-origin
  1052.  
  1053.  
  1054. document.body.appendChild(document.createElement('fullchan-x'));
  1055. const style = document.createElement('style');
  1056. style.textContent = `
  1057. body.fcx-hide-stubs .postCell:has(.unhideButton.glowOnHover) {
  1058. display: none !important;
  1059. }
  1060.  
  1061. body.fcx-anonymize .spanId {
  1062. display: none !important;
  1063. }
  1064.  
  1065. #fcx-settings-panel input[type="number"] {
  1066. background: var(--contrast-color);
  1067. color: var(--link-color);
  1068. border: 1px solid var(--navbar-text-color);
  1069. padding: 4px;
  1070. border-radius: 4px;
  1071. box-sizing: border-box;
  1072. max-width: 80px;
  1073. }
  1074.  
  1075. `;
  1076. document.head.appendChild(style);
  1077.  
  1078.  
Add Comment
Please, Sign In to add comment