Fullchan X

8chan features script

目前为 2025-04-22 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Fullchan X
  3. // @namespace Violentmonkey Scripts
  4. // @match https://8chan.moe/*/res/*
  5. // @match https://8chan.se/*/res/*
  6. // @match https://8chan.moe/*/catalog*
  7. // @match https://8chan.se/*/catalog*
  8. // @run-at document-end
  9. // @grant none
  10. // @version 1.11.0
  11. // @author vfyxe
  12. // @description 8chan features script
  13. // ==/UserScript==
  14.  
  15. class fullChanX extends HTMLElement {
  16. constructor() {
  17. super();
  18. this.settingsEl = document.querySelector('fullchan-x-settings');
  19. this.settingsAll = this.settingsEl.settings;
  20. this.settings = this.settingsAll.main;
  21. this.settingsMascot = this.settingsAll.mascot;
  22. this.isThread = !!document.querySelector('.opCell');
  23. this.isDisclaimer = window.location.href.includes('disclaimer');
  24. Object.keys(this.settings).forEach(key => {
  25. this[key] = this.settings[key]?.value;
  26. });
  27. }
  28.  
  29. init() {
  30. this.settingsButton = this.querySelector('#fcx-settings-btn');
  31. this.settingsButton.addEventListener('click', () => this.settingsEl.toggle());
  32. this.handleBoardLinks();
  33. if (!this.isThread) return;
  34.  
  35. this.quickReply = document.querySelector('#quick-reply');
  36. this.qrbody = document.querySelector('#qrbody');
  37. this.threadParent = document.querySelector('#divThreads');
  38. this.threadId = this.threadParent.querySelector('.opCell').id;
  39. this.thread = this.threadParent.querySelector('.divPosts');
  40. this.posts = [...this.thread.querySelectorAll('.postCell')];
  41. this.postOrder = 'default';
  42. this.postOrderSelect = this.querySelector('#thread-sort');
  43. this.myYousLabel = this.querySelector('.my-yous__label');
  44. this.yousContainer = this.querySelector('#my-yous');
  45.  
  46. this.gallery = document.querySelector('fullchan-x-gallery');
  47. this.galleryButton = this.querySelector('#fcx-gallery-btn');
  48.  
  49. this.updateYous();
  50. this.observers();
  51.  
  52. if (this.enableFileExtentions) this.handleTruncatedFilenames();
  53. if (this.settingsMascot.enableMascot.value) this.showMascot(this.settingsMascot);
  54. }
  55.  
  56. styleUI () {
  57. this.style.setProperty('--top', this.uiTopPosition);
  58. this.style.setProperty('--right', this.uiRightPosition);
  59. this.classList.toggle('fcx-in-nav', this.moveToNave)
  60. this.classList.toggle('fcx--dim', this.uiDimWhenInactive && !this.moveToNave);
  61. this.classList.toggle('page-thread', this.isThread);
  62. const style = document.createElement('style');
  63.  
  64. if (this.hideDefaultBoards !== '') {
  65. style.textContent += '#navTopBoardsSpan{display:block!important;}'
  66. }
  67. document.body.appendChild(style);
  68. }
  69.  
  70. handleBoardLinks () {
  71. const navBoards = document.querySelector('#navTopBoardsSpan');
  72. const customBoardLinks = this.customBoardLinks?.toLowerCase().replace(/\s/g,'').split(',');
  73. let hideDefaultBoards = this.hideDefaultBoards?.toLowerCase().replace(/\s/g,'') || '';
  74. const urlCatalog = this.catalogBoardLinks ? '/catalog.html' : '';
  75.  
  76.  
  77. if (hideDefaultBoards === 'all') {
  78. navBoards.classList.add('hidden');
  79. } else {
  80. const waitForNavBoards = setInterval(() => {
  81. const navBoards = document.querySelector('#navTopBoardsSpan');
  82. if (!navBoards || !navBoards.querySelector('a')) return;
  83.  
  84. clearInterval(waitForNavBoards);
  85.  
  86. hideDefaultBoards = hideDefaultBoards.split(',');
  87. const defaultLinks = [...navBoards.querySelectorAll('a')];
  88. defaultLinks.forEach(link => {
  89. link.href += urlCatalog;
  90. const linkText = link.textContent;
  91. const shouldHide = hideDefaultBoards.includes(linkText) || customBoardLinks.includes(linkText);
  92. link.classList.toggle('hidden', shouldHide);
  93. });
  94. }, 50);
  95. }
  96.  
  97. if (this.customBoardLinks.length > 0) {
  98. const customNav = document.createElement('span');
  99. customNav.classList = 'nav-boards nav-boards--custom';
  100. customNav.innerHTML = '<span>[</span>';
  101.  
  102. customBoardLinks.forEach((board, index) => {
  103. const link = document.createElement('a');
  104. link.href = '/' + board + urlCatalog;
  105. link.textContent = board;
  106. customNav.appendChild(link);
  107. if (index < customBoardLinks.length - 1) customNav.innerHTML += '<span>/</span>';
  108. });
  109.  
  110. customNav.innerHTML += '<span>]</span>';
  111. navBoards.parentNode.insertBefore(customNav, navBoards);
  112. }
  113. }
  114.  
  115. observers () {
  116. this.postOrderSelect.addEventListener('change', (event) => {
  117. this.postOrder = event.target.value;
  118. this.assignPostOrder();
  119. });
  120.  
  121. const observerCallback = (mutationsList, observer) => {
  122. for (const mutation of mutationsList) {
  123. if (mutation.type === 'childList') {
  124. this.posts = [...this.thread.querySelectorAll('.postCell')];
  125. if (this.postOrder !== 'default') this.assignPostOrder();
  126. this.updateYous();
  127. this.gallery.updateGalleryImages();
  128. if (this.settings.enableFileExtentions) this.handleTruncatedFilenames();
  129. }
  130. }
  131. };
  132.  
  133. const threadObserver = new MutationObserver(observerCallback);
  134. threadObserver.observe(this.thread, { childList: true, subtree: false });
  135.  
  136. if (this.enableNestedQuotes) {
  137. this.thread.addEventListener('click', event => {
  138. this.handleClick(event);
  139. });
  140. }
  141.  
  142. this.galleryButton.addEventListener('click', () => this.gallery.open());
  143. this.myYousLabel.addEventListener('click', (event) => {
  144. if (this.myYousLabel.classList.contains('unseen')) {
  145. this.yousContainer.querySelector('.unseen').click();
  146. }
  147. });
  148. }
  149.  
  150. handleClick (event) {
  151. const clicked = event.target;
  152.  
  153. const post = clicked.closest('.innerPost') || clicked.closest('.innerOP');
  154. if (!post) return;
  155.  
  156. const isNested = !!post.closest('.innerNested');
  157. const nestQuote = clicked.closest('.quoteLink') || clicked.closest('.panelBacklinks a');
  158. const postMedia = clicked.closest('a[data-filemime]');
  159. const postId = clicked.closest('.linkQuote');
  160. const anonId = clicked.closest('.labelId');
  161.  
  162. if (nestQuote) {
  163. if (event.target.closest('.fcx-prevent-nesting')) return;
  164. event.preventDefault();
  165. this.nestQuote(nestQuote, post);
  166. } else if (postMedia && isNested) {
  167. this.handleMediaClick(event, postMedia);
  168. } else if (postId && isNested) {
  169. this.handleIdClick(postId);
  170. } else if (anonId) {
  171. this.handleAnonIdClick(anonId, event);
  172. }
  173. }
  174.  
  175. handleAnonIdClick (anonId, event) {
  176. this.anonIdPosts?.remove();
  177. if (anonId === this.anonId) {
  178. this.anonId = null;
  179. return;
  180. }
  181.  
  182. this.anonId = anonId;
  183. const anonIdText = anonId.textContent.split(' ')[0];
  184. this.anonIdPosts = document.createElement('div');
  185. this.anonIdPosts.classList = 'fcx-id-posts fcx-prevent-nesting';
  186.  
  187. const match = window.location.pathname.match(/^\/[^/]+\/res\/\d+\.html/);
  188. const prepend = match ? `${match[0]}#` : '';
  189.  
  190. const selector = `.postInfo:has(.labelId[style="background-color: #${anonIdText}"]) .linkQuote`;
  191.  
  192. const postIds = [...this.threadParent.querySelectorAll(selector)].map(link => {
  193. const postId = link.getAttribute('href').split('#q').pop();
  194. const newLink = document.createElement('a');
  195. newLink.className = 'quoteLink';
  196. newLink.href = prepend + postId;
  197. newLink.textContent = `>>${postId}`;
  198. console.log('newLink',newLink)
  199. return newLink;
  200. });
  201.  
  202. postIds.forEach(postId => this.anonIdPosts.appendChild(postId));
  203. anonId.insertAdjacentElement('afterend', this.anonIdPosts);
  204.  
  205. this.setPostListeners(this.anonIdPosts);
  206. }
  207.  
  208.  
  209. handleMediaClick (event, postMedia) {
  210. if (postMedia.dataset.filemime === "video/webm") return;
  211. event.preventDefault();
  212. const imageSrc = `${postMedia.href}`;
  213. const imageEl = postMedia.querySelector('img');
  214. if (!postMedia.dataset.thumbSrc) postMedia.dataset.thumbSrc = `${imageEl.src}`;
  215.  
  216. const isExpanding = imageEl.src !== imageSrc;
  217.  
  218. if (isExpanding) {
  219. imageEl.src = imageSrc;
  220. imageEl.classList
  221. }
  222. imageEl.src = isExpanding ? imageSrc : postMedia.dataset.thumbSrc;
  223. imageEl.classList.toggle('imgExpanded', isExpanding);
  224. }
  225.  
  226. handleIdClick (postId) {
  227. const idNumber = '>>' + postId.textContent;
  228. this.quickReply.style.display = 'block';
  229. this.qrbody.value += idNumber + '\n';
  230. }
  231.  
  232. handleTruncatedFilenames () {
  233. this.postFileNames = [...this.threadParent.querySelectorAll('.originalNameLink[download]:not([data-file-ext])')];
  234. this.postFileNames.forEach(fileName => {
  235. if (!fileName.textContent.includes('.')) return;
  236. const strings = fileName.textContent.split('.');
  237. const typeStr = `.${strings.pop()}`;
  238. const typeEl = document.createElement('a');
  239. typeEl.classList = ('file-ext originalNameLink');
  240. typeEl.textContent = typeStr;
  241. fileName.dataset.fileExt = typeStr;
  242. fileName.textContent = strings.join('.');
  243. fileName.parentNode.insertBefore(typeEl, fileName.nextSibling);
  244. });
  245. }
  246.  
  247. assignPostOrder () {
  248. const postOrderReplies = (post) => {
  249. const replyCount = post.querySelectorAll('.panelBacklinks a').length;
  250. post.style.order = 100 - replyCount;
  251. }
  252.  
  253. const postOrderCatbox = (post) => {
  254. const postContent = post.querySelector('.divMessage').textContent;
  255. const matches = postContent.match(/catbox\.moe/g);
  256. const catboxCount = matches ? matches.length : 0;
  257. post.style.order = 100 - catboxCount;
  258. }
  259.  
  260. if (this.postOrder === 'default') {
  261. this.thread.style.display = 'block';
  262. return;
  263. }
  264.  
  265. this.thread.style.display = 'flex';
  266.  
  267. if (this.postOrder === 'replies') {
  268. this.posts.forEach(post => postOrderReplies(post));
  269. } else if (this.postOrder === 'catbox') {
  270. this.posts.forEach(post => postOrderCatbox(post));
  271. }
  272. }
  273.  
  274. updateYous () {
  275. this.yous = this.posts.filter(post => post.querySelector('.quoteLink.you'));
  276. this.yousLinks = this.yous.map(you => {
  277. const youLink = document.createElement('a');
  278. youLink.textContent = '>>' + you.id;
  279. youLink.href = '#' + you.id;
  280. return youLink;
  281. })
  282.  
  283. let hasUnseenYous = false;
  284. this.setUnseenYous();
  285.  
  286. this.yousContainer.innerHTML = '';
  287. this.yousLinks.forEach(you => {
  288. const youId = you.textContent.replace('>>', '');
  289. if (!this.seenYous.includes(youId)) {
  290. you.classList.add('unseen');
  291. hasUnseenYous = true
  292. }
  293. this.yousContainer.appendChild(you)
  294. });
  295.  
  296. this.myYousLabel.classList.toggle('unseen', hasUnseenYous);
  297.  
  298. if (this.replyTabIcon === '') return;
  299. const icon = this.replyTabIcon;
  300. document.title = hasUnseenYous
  301. ? document.title.startsWith(`${icon} `)
  302. ? document.title
  303. : `${icon} ${document.title}`
  304. : document.title.replace(new RegExp(`^${icon} `), '');
  305. }
  306.  
  307. observeUnseenYou(you) {
  308. you.classList.add('observe-you');
  309.  
  310. const observer = new IntersectionObserver((entries, observer) => {
  311. entries.forEach(entry => {
  312. if (entry.isIntersecting) {
  313. const id = you.id;
  314. you.classList.remove('observe-you');
  315.  
  316. if (!this.seenYous.includes(id)) {
  317. this.seenYous.push(id);
  318. localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
  319. }
  320.  
  321. observer.unobserve(you);
  322. this.updateYous();
  323.  
  324. }
  325. });
  326. }, { rootMargin: '0px', threshold: 0.1 });
  327.  
  328. observer.observe(you);
  329. }
  330.  
  331. setUnseenYous() {
  332. this.seenKey = `${this.threadId}-seen-yous`;
  333. this.seenYous = JSON.parse(localStorage.getItem(this.seenKey));
  334.  
  335. if (!this.seenYous) {
  336. this.seenYous = [];
  337. localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
  338. }
  339.  
  340. this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id));
  341.  
  342. this.unseenYous.forEach(you => {
  343. if (!you.classList.contains('observe-you')) {
  344. this.observeUnseenYou(you);
  345. }
  346. });
  347. }
  348.  
  349. nestQuote(quoteLink, parentPost) {
  350. const parentPostMessage = parentPost.querySelector('.divMessage');
  351. const quoteId = quoteLink.href.split('#').pop();
  352. const quotePost = document.getElementById(quoteId);
  353. if (!quotePost) return;
  354.  
  355. const quotePostContent = quotePost.querySelector('.innerOP') || quotePost.querySelector('.innerPost');
  356. if (!quotePostContent) return;
  357.  
  358. const existing = parentPost.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`);
  359. if (existing) {
  360. existing.remove();
  361. return;
  362. }
  363.  
  364. const isReply = !quoteLink.classList.contains('quoteLink');
  365.  
  366. const wrapper = document.createElement('div');
  367. wrapper.classList.add('nestedPost');
  368. wrapper.setAttribute('data-quote-id', quoteId);
  369.  
  370. const clone = quotePostContent.cloneNode(true);
  371. clone.style.whiteSpace = 'unset';
  372. clone.classList.add('innerNested');
  373. wrapper.appendChild(clone);
  374.  
  375. if (isReply) {
  376. parentPostMessage.insertBefore(wrapper, parentPostMessage.firstChild);
  377. } else {
  378. quoteLink.insertAdjacentElement('afterend', wrapper);
  379. }
  380.  
  381. this.setPostListeners(wrapper);
  382. }
  383.  
  384. setPostListeners(parentPost) {
  385. const postLinks = [
  386. ...parentPost.querySelectorAll('.quoteLink'),
  387. ...parentPost.querySelectorAll('.panelBacklinks a')
  388. ];
  389.  
  390. const hoverPost = (event, link) => {
  391. const quoteId = link.href.split('#')[1];
  392.  
  393. let existingPost = document.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`)
  394. || link.closest(`.postCell[id="${quoteId}"]`);
  395.  
  396. if (existingPost) {
  397. this.markedPost = existingPost.querySelector('.innerPost') || existingPost.querySelector('.innerOP');
  398. this.markedPost?.classList.add('markedPost');
  399. return;
  400. }
  401.  
  402. const quotePost = document.getElementById(quoteId);
  403.  
  404. tooltips.removeIfExists();
  405.  
  406. const tooltip = document.createElement('div');
  407. tooltip.className = 'quoteTooltip';
  408. document.body.appendChild(tooltip);
  409.  
  410. const rect = link.getBoundingClientRect();
  411. if (!api.mobile) {
  412. if (rect.left > window.innerWidth / 2) {
  413. const right = window.innerWidth - rect.left - window.scrollX;
  414. tooltip.style.right = `${right}px`;
  415. } else {
  416. const left = rect.right + 10 + window.scrollX;
  417. tooltip.style.left = `${left}px`;
  418. }
  419. }
  420.  
  421. tooltip.style.top = `${rect.top + window.scrollY}px`;
  422. tooltip.style.display = 'inline';
  423.  
  424. tooltips.loadTooltip(tooltip, link.href, quoteId);
  425. tooltips.currentTooltip = tooltip;
  426. }
  427.  
  428. const unHoverPost = (event, link) => {
  429. if (!tooltips.currentTooltip) {
  430. this.markedPost?.classList.remove('markedPost');
  431. return false;
  432. }
  433.  
  434. if (tooltips.unmarkReply) {
  435. tooltips.currentTooltip.classList.remove('markedPost');
  436. Array.from(tooltips.currentTooltip.getElementsByClassName('replyUnderline'))
  437. .forEach((a) => a.classList.remove('replyUnderline'))
  438. tooltips.unmarkReply = false;
  439. } else {
  440. tooltips.currentTooltip.remove();
  441. }
  442.  
  443. tooltips.currentTooltip = null;
  444. }
  445.  
  446. const addHoverPost = (link => {
  447. link.addEventListener('mouseenter', (event) => hoverPost(event, link));
  448. link.addEventListener('mouseleave', (event) => unHoverPost(event, link));
  449. });
  450.  
  451. postLinks.forEach(link => addHoverPost(link));
  452. }
  453.  
  454. showMascot(settings) {
  455. const mascot = document.createElement('img');
  456. mascot.classList.add('fcx-mascot');
  457. mascot.src = settings.image.value;
  458. mascot.style.opacity = settings.opacity.value * 0.01;
  459. mascot.style.top = settings.top.value;
  460. mascot.style.left = settings.left.value;
  461. mascot.style.right = settings.right.value;
  462. mascot.style.bottom = settings.bottom.value;
  463. mascot.style.height = settings.height.value;
  464. mascot.style.width = settings.width.value;
  465. document.body.appendChild(mascot);
  466. }
  467. };
  468.  
  469. window.customElements.define('fullchan-x', fullChanX);
  470.  
  471.  
  472. class fullChanXGallery extends HTMLElement {
  473. constructor() {
  474. super();
  475. }
  476.  
  477. init() {
  478. this.fullchanX = document.querySelector('fullchan-x');
  479. this.imageContainer = this.querySelector('.gallery__images');
  480. this.mainImageContainer = this.querySelector('.gallery__main-image');
  481. this.mainImage = this.mainImageContainer.querySelector('img');
  482. this.sizeButtons = [...this.querySelectorAll('.gallery__scale-options .scale-option')];
  483. this.closeButton = this.querySelector('.gallery__close');
  484. this.listeners();
  485. this.addGalleryImages();
  486. this.initalized = true;
  487. }
  488.  
  489. addGalleryImages () {
  490. this.thumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].map(thumb => {
  491. return thumb.cloneNode(true);
  492. });
  493.  
  494. this.thumbs.forEach(thumb => {
  495. this.imageContainer.appendChild(thumb);
  496. });
  497. }
  498.  
  499. updateGalleryImages () {
  500. if (!this.initalized) return;
  501.  
  502. const newThumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].filter(thumb => {
  503. return !this.thumbs.find(thisThumb.href === thumb.href);
  504. }).map(thumb => {
  505. return thumb.cloneNode(true);
  506. });
  507.  
  508. newThumbs.forEach(thumb => {
  509. this.thumbs.push(thumb);
  510. this.imageContainer.appendChild(thumb);
  511. });
  512. }
  513.  
  514. listeners () {
  515. this.addEventListener('click', event => {
  516. const clicked = event.target;
  517.  
  518. let imgLink = clicked.closest('.imgLink');
  519. if (imgLink?.dataset.filemime === 'video/webm') return;
  520.  
  521. if (imgLink) {
  522. event.preventDefault();
  523. this.mainImage.src = imgLink.href;
  524. }
  525.  
  526. this.mainImageContainer.classList.toggle('active', !!imgLink);
  527.  
  528. const scaleButton = clicked.closest('.scale-option');
  529. if (scaleButton) {
  530. const scale = parseFloat(getComputedStyle(this.imageContainer).getPropertyValue('--scale')) || 1;
  531. const delta = scaleButton.id === 'fcxg-smaller' ? -0.1 : 0.1;
  532. const newScale = Math.max(0.1, scale + delta);
  533. this.imageContainer.style.setProperty('--scale', newScale.toFixed(2));
  534. }
  535.  
  536. if (clicked.closest('.gallery__close')) this.close();
  537. });
  538. }
  539.  
  540. open () {
  541. if (!this.initalized) this.init();
  542. this.classList.add('open');
  543. document.body.classList.add('fct-gallery-open');
  544. }
  545.  
  546. close () {
  547. this.classList.remove('open');
  548. document.body.classList.remove('fct-gallery-open');
  549. }
  550. }
  551.  
  552. window.customElements.define('fullchan-x-gallery', fullChanXGallery);
  553.  
  554.  
  555.  
  556. class fullChanXSettings extends HTMLElement {
  557. constructor() {
  558. super();
  559. this.settingsKey = 'fullchan-x-settings';
  560. this.inputs = [];
  561. this.settings = {};
  562. this.settingsTemplate = {
  563. main: {
  564. moveToNave: {
  565. info: 'Move Fullchan-X controls into the navbar.',
  566. type: 'checkbox',
  567. value: false
  568. },
  569. enableNestedQuotes: {
  570. info: 'Nest posts when clicking backlinks.',
  571. type: 'checkbox',
  572. value: true
  573. },
  574. enableFileExtentions: {
  575. info: 'Always show filetype on shortened file names.',
  576. type: 'checkbox',
  577. value: true
  578. },
  579. customBoardLinks: {
  580. info: 'List of custom boards in nav (seperate by comma)',
  581. type: 'input',
  582. value: 'v,a,b'
  583. },
  584. hideDefaultBoards: {
  585. info: 'List of boards to remove from nav (seperate by comma). Set as "all" to remove all.',
  586. type: 'input',
  587. value: 'interracial,mlp'
  588. },
  589. catalogBoardLinks: {
  590. info: 'Redirect nav board links to catalog pages.',
  591. type: 'checkbox',
  592. value: true
  593. },
  594. uiTopPosition: {
  595. info: 'Position from top of screen e.g. 100px',
  596. type: 'input',
  597. value: '50px'
  598. },
  599. uiRightPosition: {
  600. info: 'Position from right of screen e.g. 100px',
  601. type: 'input',
  602. value: '25px'
  603. },
  604. uiDimWhenInactive: {
  605. info: 'Dim UI when not hovering with mouse.',
  606. type: 'checkbox',
  607. value: true
  608. },
  609. replyTabIcon: {
  610. info: 'Set the icon/text added to tab title when you get a new (You).',
  611. type: 'input',
  612. value: '❗'
  613. }
  614. },
  615. mascot: {
  616. enableMascot: {
  617. info: 'Enable mascot image.',
  618. type: 'checkbox',
  619. value: false
  620. },
  621. image: {
  622. info: 'Image URL (8chan image recommended).',
  623. type: 'input',
  624. value: '/.static/logo.png'
  625. },
  626. opacity: {
  627. info: 'Opacity (1 to 100)',
  628. type: 'input',
  629. inputType: 'number',
  630. value: '75'
  631. },
  632. width: {
  633. info: 'Width of image.',
  634. type: 'input',
  635. value: '300px'
  636. },
  637. height: {
  638. info: 'Height of image.',
  639. type: 'input',
  640. value: 'auto'
  641. },
  642. bottom: {
  643. info: 'Bottom position.',
  644. type: 'input',
  645. value: '0px'
  646. },
  647. right: {
  648. info: 'Right position.',
  649. type: 'input',
  650. value: '0px'
  651. },
  652. top: {
  653. info: 'Top position.',
  654. type: 'input',
  655. value: ''
  656. },
  657. left: {
  658. info: 'Left position.',
  659. type: 'input',
  660. value: ''
  661. }
  662. }
  663. };
  664. }
  665.  
  666. init() {
  667. this.settingsMain = this.querySelector('.fcxs-main');
  668. this.settingsMascot = this.querySelector('.fcxs-mascot');
  669. this.getSavedSettings();
  670. this.buildSettingsOptions('main', this.settingsMain);
  671. this.buildSettingsOptions('mascot', this.settingsMascot);
  672. this.listeners();
  673. this.querySelector('.fcx-settings__close').addEventListener('click', () => this.close());
  674. }
  675.  
  676. setSavedSettings(updated) {
  677. localStorage.setItem(this.settingsKey, JSON.stringify(this.settings));
  678. if (updated) this.classList.add('fcxs-updated');
  679. }
  680.  
  681. getSavedSettings() {
  682. let saved = JSON.parse(localStorage.getItem(this.settingsKey));
  683. if (!saved) return;
  684.  
  685. // Ensure all top-level keys exist
  686. for (const key in this.settingsTemplate) {
  687. if (!saved[key]) saved[key] = {};
  688. }
  689.  
  690. this.settings = saved;
  691. }
  692.  
  693. listeners() {
  694. this.inputs.forEach(input => {
  695. input.addEventListener('change', () => {
  696. const section = input.dataset.section;
  697. const key = input.name;
  698. const value = input.type === 'checkbox' ? input.checked : input.value;
  699. this.settings[section][key].value = value;
  700. this.setSavedSettings(true);
  701. });
  702. });
  703. }
  704.  
  705. buildSettingsOptions(subSettings, parent) {
  706. Object.entries(this.settingsTemplate[subSettings]).forEach(([key, config]) => {
  707. const wrapper = document.createElement('div');
  708. const infoWrapper = document.createElement('div');
  709. wrapper.classList.add('fcx-setting');
  710. infoWrapper.classList.add('fcx-setting__info');
  711. wrapper.appendChild(infoWrapper);
  712.  
  713. const label = document.createElement('label');
  714. label.textContent = key
  715. .replace(/([A-Z])/g, ' $1')
  716. .replace(/^./, str => str.toUpperCase());
  717. label.setAttribute('for', key);
  718. infoWrapper.appendChild(label);
  719.  
  720. if (config.info) {
  721. const info = document.createElement('p');
  722. info.textContent = config.info;
  723. infoWrapper.appendChild(info);
  724. }
  725.  
  726. const savedValue = this.settings[subSettings][key]?.value ?? config.value;
  727. let input;
  728.  
  729. if (config.type === 'checkbox') {
  730. input = document.createElement('input');
  731. input.type = 'checkbox';
  732. input.checked = savedValue;
  733. } else if (config.type === 'input') {
  734. input = document.createElement('input');
  735. input.type = input.inputType || 'text';
  736. input.value = savedValue;
  737. } else if (config.type === 'select' && config.options) {
  738. input = document.createElement('select');
  739. const options = config.options.split(',');
  740. options.forEach(opt => {
  741. const option = document.createElement('option');
  742. option.value = opt;
  743. option.textContent = opt;
  744. if (opt === savedValue) option.selected = true;
  745. input.appendChild(option);
  746. });
  747. }
  748.  
  749. if (input) {
  750. input.id = key;
  751. input.name = key;
  752. input.dataset.section = subSettings;
  753. wrapper.appendChild(input);
  754. this.inputs.push(input);
  755. this.settings[subSettings][key] = {
  756. value: input.type === 'checkbox' ? input.checked : input.value
  757. };
  758. }
  759.  
  760. parent.appendChild(wrapper);
  761. });
  762. }
  763.  
  764. open() {
  765. this.classList.add('open');
  766. }
  767.  
  768. close() {
  769. this.classList.remove('open');
  770. }
  771.  
  772. toggle() {
  773. this.classList.toggle('open');
  774. }
  775. }
  776.  
  777. window.customElements.define('fullchan-x-settings', fullChanXSettings);
  778.  
  779.  
  780.  
  781. class ToggleButton extends HTMLElement {
  782. constructor() {
  783. super();
  784. const data = this.dataset;
  785. this.onclick = () => {
  786. const target = data.target ? document.querySelector(data.target) : this;
  787. const value = data.value || 'active';
  788. !!data.set ? target.dataset[data.set] = value : target.classList.toggle(value);
  789. }
  790. }
  791. }
  792.  
  793. window.customElements.define('toggle-button', ToggleButton);
  794.  
  795.  
  796.  
  797. // Create fullchan-x settings
  798. const fcxs = document.createElement('fullchan-x-settings');
  799. fcxs.innerHTML = `
  800. <div class="fcx-settings fcxs" data-tab="main">
  801. <header>
  802. <div class="fcxs__heading">
  803. <span class="fcx-settings__title">
  804. Fullchan-X Settings
  805. </span>
  806. <button class="fcx-settings__close fullchan-x__option">Close</button>
  807. </div>
  808.  
  809. <div class="fcx-settings__tab-buttons">
  810. <toggle-button data-target=".fcxs" data-set="tab" data-value="main">
  811. Main
  812. </toggle-button>
  813. <toggle-button data-target=".fcxs" data-set="tab" data-value="mascot">
  814. Mascot
  815. </toggle-button>
  816. </div>
  817. </header>
  818.  
  819. <main>
  820. <div class="fcxs__updated-message">
  821. <p>Settings updated, refresh page to apply</p>
  822. <button class="fullchan-x__option" onClick="location.reload()">Reload page</button>
  823. </div>
  824.  
  825. <div class="fcx-settings__settings">
  826. <div class="fcxs-main fcxs-tab"></div>
  827. <div class="fcxs-mascot fcxs-tab"></div>
  828. </div>
  829. </main>
  830.  
  831. <footer>
  832. </footer>
  833. </div>
  834. `;
  835. document.body.appendChild(fcxs);
  836. fcxs.init();
  837.  
  838.  
  839.  
  840. // Create fullchan-x gallery
  841. const fcxg = document.createElement('fullchan-x-gallery');
  842. fcxg.innerHTML = `
  843. <div class="fcxg gallery">
  844. <button id="fcxg-close" class="gallery__close fullchan-x__option">Close</button>
  845. <div class="gallery__scale-options">
  846. <button id="fcxg-smaller" class="scale-option fullchan-x__option">-</button>
  847. <button id="fcxg-larger" class="scale-option fullchan-x__option">+</button>
  848. </div>
  849. <div id="fcxg-images" class="gallery__images" style="--scale:1.0"></div>
  850. <div id="fcxg-main-image" class="gallery__main-image">
  851. <img src="" />
  852. </div>
  853. </div>
  854. `;
  855. document.body.appendChild(fcxg);
  856.  
  857.  
  858.  
  859. // Create fullchan-x element
  860. const fcx = document.createElement('fullchan-x');
  861. fcx.innerHTML = `
  862. <div class="fcx__controls">
  863. <button id="fcx-settings-btn" class="fullchan-x__option fcx-settings-toggle">
  864. <a>⚙️</a><span>Settings</span>
  865. </button>
  866.  
  867. <div class="fullchan-x__option fullchan-x__sort thread-only">
  868. <a>☰</a>
  869. <select id="thread-sort">
  870. <option value="default">Default</option>
  871. <option value="replies">Replies</option>
  872. <option value="catbox">Catbox</option>
  873. </select>
  874. </div>
  875.  
  876. <button id="fcx-gallery-btn" class="gallery__toggle fullchan-x__option thread-only">
  877. <a>🖼️</a><span>Gallery</span>
  878. </button>
  879.  
  880. <div class="fcx__my-yous thread-only">
  881. <p class="my-yous__label fullchan-x__option"><a>💬</a><span>My (You)s</span></p>
  882. <div class="my-yous__yous fcx-prevent-nesting" id="my-yous"></div>
  883. </div>
  884. </div>
  885. `;
  886. (document.querySelector('#dynamicHeaderThread') || document.body).appendChild(fcx);
  887. fcx.styleUI()
  888. onload = (event) => fcx.init();
  889.  
  890. // Styles
  891. const style = document.createElement('style');
  892. style.innerHTML = `
  893. fullchan-x {
  894. --top: 50px;
  895. --right: 25px;
  896. background: var(--background-color);
  897. border: 1px solid var(--navbar-text-color);
  898. color: var(--link-color);
  899. font-size: 14px;
  900. z-index: 3;
  901. }
  902.  
  903. toggle-button {
  904. cursor: pointer;
  905. }
  906.  
  907. /* Fullchan-X in nav styles */
  908. .fcx-in-nav {
  909. padding: 0;
  910. border-width: 0;
  911. line-height: 20px;
  912. margin-right: 2px;
  913. background: none;
  914. }
  915.  
  916. .fcx-in-nav .fcx__controls:before,
  917. .fcx-in-nav .fcx__controls:after {
  918. color: var(--navbar-text-color);
  919. font-size: 85%;
  920. }
  921.  
  922. .fcx-in-nav .fcx__controls:before {
  923. content: "]";
  924. }
  925.  
  926. .fcx-in-nav .fcx__controls:after {
  927. content: "[";
  928. }
  929.  
  930. .fcx-in-nav .fcx__controls,
  931. .fcx-in-nav:hover .fcx__controls:hover {
  932. flex-direction: row-reverse;
  933. }
  934.  
  935. .fcx-in-nav .fcx__controls .fullchan-x__option {
  936. padding: 0!important;
  937. justify-content: center;
  938. background: none;
  939. line-height: 0;
  940. max-width: 20px;
  941. min-width: 20px;
  942. translate: 0 1px;
  943. border: solid var(--navbar-text-color) 1px !important;
  944. }
  945.  
  946. .fcx-in-nav .fcx__controls .fullchan-x__option:hover {
  947. border: solid var(--subject-color) 1px !important;
  948. }
  949.  
  950. .fcx-in-nav .fullchan-x__sort > a {
  951. margin-bottom: 1px;
  952. }
  953.  
  954. .fcx-in-nav .fcx__controls > * {
  955. position: relative;
  956. }
  957.  
  958. .fcx-in-nav .fcx__controls .fullchan-x__option > span,
  959. .fcx-in-nav .fcx__controls .fullchan-x__option:not(:hover) > select {
  960. display: none;
  961. }
  962.  
  963. .fcx-in-nav .fcx__controls .fullchan-x__option > select {
  964. appearance: none;
  965. position: absolute;
  966. left: 0;
  967. top: 0;
  968. width: 100%;
  969. height: 100%;
  970. font-size: 0;
  971. }
  972.  
  973. .fcx-in-nav .fcx__controls .fullchan-x__option > select option {
  974. font-size: 12px;
  975. }
  976.  
  977. .fcx-in-nav .my-yous__yous {
  978. position: absolute;
  979. left: 50%;
  980. translate: -50%;
  981. background: var(--background-color);
  982. border: 1px solid var(--navbar-text-color);
  983. padding: 14px;
  984. }
  985.  
  986. /* Fullchan-X main styles */
  987. fullchan-x:not(.fcx-in-nav) {
  988. top: var(--top);
  989. right: var(--right);
  990. display: block;
  991. padding: 10px;
  992. position: fixed;
  993. display: block;
  994. }
  995.  
  996. fullchan-x:not(.page-thread) .thread-only,
  997. fullchan-x:not(.page-catalog) .catalog-only {
  998. display: none!important;
  999. }
  1000.  
  1001. fullchan-x:hover {
  1002. z-index: 1000!important;
  1003. }
  1004.  
  1005. .navHeader:has(fullchan-x:hover) {
  1006. z-index: 1000!important;
  1007. }
  1008.  
  1009. fullchan-x.fcx--dim:not(:hover) {
  1010. opacity: 0.6;
  1011. }
  1012.  
  1013. .divPosts {
  1014. flex-direction: column;
  1015. }
  1016.  
  1017. .fcx__controls {
  1018. display: flex;
  1019. flex-direction: column;
  1020. gap: 6px;
  1021. }
  1022.  
  1023. fullchan-x:not(:hover):not(:has(select:focus)) span,
  1024. fullchan-x:not(:hover):not(:has(select:focus)) select {
  1025. display: none;
  1026. margin-left: 5px;
  1027. z-index:3;
  1028. }
  1029.  
  1030. .fcx__controls span,
  1031. .fcx__controls select {
  1032. margin-left: 5px;
  1033. }
  1034.  
  1035. .fcx__controls select {
  1036. cursor: pointer;
  1037. }
  1038.  
  1039. #thread-sort {
  1040. border: none;
  1041. background: none;
  1042. }
  1043.  
  1044. .my-yous__yous {
  1045. display: none;
  1046. flex-direction: column;
  1047. padding-top: 10px;
  1048. max-height: calc(100vh - 220px - var(--top));
  1049. overflow: auto;
  1050. }
  1051.  
  1052. .fcx__my-yous:hover .my-yous__yous {
  1053. display: flex;
  1054. }
  1055.  
  1056. .fullchan-x__option {
  1057. display: flex;
  1058. padding: 6px 8px;
  1059. background: white;
  1060. border: none !important;
  1061. border-radius: 0.2rem;
  1062. transition: all ease 150ms;
  1063. cursor: pointer;
  1064. margin: 0;
  1065. text-align: left;
  1066. min-width: 18px;
  1067. min-height: 18px;
  1068. align-items: center;
  1069. }
  1070.  
  1071. .fullchan-x__option,
  1072. .fullchan-x__option select {
  1073. font-size: 12px;
  1074. font-weight: 400;
  1075. color: #374369;
  1076. }
  1077.  
  1078. fullchan-x:not(:hover):not(:has(select:focus)) .fullchan-x__option {
  1079. display: flex;
  1080. justify-content: center;
  1081. }
  1082.  
  1083. #thread-sort {
  1084. padding-right: 0;
  1085. }
  1086.  
  1087. #thread-sort:hover {
  1088. display: block;
  1089. }
  1090.  
  1091. .innerPost:has(.quoteLink.you) {
  1092. border-left: solid #dd003e 6px;
  1093. }
  1094.  
  1095. .innerPost:has(.youName) {
  1096. border-left: solid #68b723 6px;
  1097. }
  1098.  
  1099. /* --- Nested quotes --- */
  1100. .divMessage .nestedPost {
  1101. display: inline-block;
  1102. width: 100%;
  1103. margin-bottom: 14px;
  1104. white-space: normal!important;
  1105. overflow-wrap: anywhere;
  1106. margin-top: 0.5em;
  1107. border: 1px solid var(--navbar-text-color);
  1108. }
  1109.  
  1110. .nestedPost .innerPost,
  1111. .nestedPost .innerOP {
  1112. width: 100%;
  1113. }
  1114.  
  1115. .nestedPost .imgLink .imgExpanded {
  1116. width: auto!important;
  1117. height: auto!important;
  1118. }
  1119.  
  1120. .my-yous__label.unseen {
  1121. background: var(--link-hover-color)!important;
  1122. color: white;
  1123. }
  1124.  
  1125. .my-yous__yous .unseen {
  1126. font-weight: 900;
  1127. color: var(--link-hover-color);
  1128. }
  1129.  
  1130. /*--- Settings --- */
  1131. .fcx-settings {
  1132. display: block;
  1133. position: fixed;
  1134. top: 50vh;
  1135. left: 50vw;
  1136. translate: -50% -50%;
  1137. padding: 20px 0;
  1138. background: var(--background-color);
  1139. border: 1px solid var(--navbar-text-color);
  1140. color: var(--link-color);
  1141. font-size: 14px;
  1142. max-width: 480px;
  1143. max-height: 80vh;
  1144. overflow: scroll;
  1145. min-width: 500px;
  1146. z-index: 1000;
  1147. }
  1148.  
  1149. fullchan-x-settings:not(.open) {
  1150. display: none;
  1151. }
  1152.  
  1153. .fcxs__heading,
  1154. .fcxs-tab,
  1155. .fcxs footer {
  1156. padding: 0 20px;
  1157. }
  1158.  
  1159. .fcx-settings header {
  1160. margin: 0 0 15px;
  1161. border-bottom: 1px solid var(--navbar-text-color);
  1162. }
  1163.  
  1164. .fcxs__heading {
  1165. display: flex;
  1166. align-items: center;
  1167. justify-content: space-between;
  1168. padding-bottom: 20px;
  1169. }
  1170.  
  1171. .fcx-settings__title {
  1172. font-size: 24px;
  1173. font-size: 24px;
  1174. letter-spacing: 0.04em;
  1175. }
  1176.  
  1177. .fcx-settings__tab-buttons {
  1178. border-top: 1px solid var(--navbar-text-color);
  1179. display: flex;
  1180. align-items: center;
  1181. }
  1182.  
  1183. .fcx-settings__tab-buttons toggle-button {
  1184. flex: 1;
  1185. padding: 15px;
  1186. font-size: 14px;
  1187. }
  1188.  
  1189. .fcx-settings__tab-buttons toggle-button + toggle-button {
  1190. border-left: 1px solid var(--navbar-text-color);
  1191. }
  1192.  
  1193. .fcx-settings__tab-buttons toggle-button: hover {
  1194. color: var(--subject-color);
  1195. }
  1196.  
  1197. fullchan-x-settings:not(.fcxs-updated) .fcxs__updated-message {
  1198. display: none;
  1199. }
  1200.  
  1201. .fcxs:not([data-tab="main"]) .fcxs-main,
  1202. .fcxs:not([data-tab="mascot"]) .fcxs-mascot {
  1203. display: none;
  1204. }
  1205.  
  1206. .fcx-setting {
  1207. display: flex;
  1208. justify-content: space-between;
  1209. align-items: center;
  1210. padding: 12px 0;
  1211. }
  1212.  
  1213. .fcx-setting__info {
  1214. max-width: 60%;
  1215. }
  1216.  
  1217. .fcx-setting input[type="text"],
  1218. .fcx-setting select {
  1219. padding: 4px 6px;
  1220. min-width: 35%;
  1221. }
  1222.  
  1223. .fcx-setting label {
  1224. font-weight: 600;
  1225. }
  1226.  
  1227. .fcx-setting p {
  1228. margin: 6px 0 0;
  1229. font-size: 12px;
  1230. }
  1231.  
  1232. .fcx-setting + .fcx-setting {
  1233. border-top: 1px solid var(--navbar-text-color);
  1234. }
  1235.  
  1236. .fcxs__updated-message {
  1237. margin: 10px 0;
  1238. text-align: center;
  1239. }
  1240.  
  1241. .fcxs__updated-message p {
  1242. font-size: 14px;
  1243. color: var(--error);
  1244. }
  1245.  
  1246. .fcxs__updated-message button {
  1247. margin: 14px auto 0;
  1248. }
  1249.  
  1250. /* --- Gallery --- */
  1251. .fct-gallery-open,
  1252. body.fct-gallery-open,
  1253. body.fct-gallery-open #mainPanel {
  1254. overflow: hidden!important;
  1255. }
  1256.  
  1257. body.fct-gallery-open fullchan-x:not(.fcx-in-nav),
  1258. body.fct-gallery-open #quick-reply {
  1259. display: none!important;
  1260. }
  1261.  
  1262. fullchan-x-gallery {
  1263. position: fixed;
  1264. top: 0;
  1265. left: 0;
  1266. width: 100%;
  1267. background: rgba(0,0,0,0.9);
  1268. display: none;
  1269. height: 100%;
  1270. overflow: auto;
  1271. }
  1272.  
  1273. fullchan-x-gallery.open {
  1274. display: block;
  1275. }
  1276.  
  1277. fullchan-x-gallery .gallery {
  1278. padding: 50px 10px 0
  1279. }
  1280.  
  1281. fullchan-x-gallery .gallery__images {
  1282. --scale: 1.0;
  1283. display: flex;
  1284. width: 100%;
  1285. height: 100%;
  1286. justify-content: center;
  1287. align-content: flex-start;
  1288. gap: 4px 8px;
  1289. flex-wrap: wrap;
  1290. }
  1291.  
  1292. fullchan-x-gallery .imgLink {
  1293. float: unset;
  1294. display: block;
  1295. zoom: var(--scale);
  1296. }
  1297.  
  1298. fullchan-x-gallery .imgLink img {
  1299. border: solid white 1px;
  1300. }
  1301.  
  1302. fullchan-x-gallery .imgLink[data-filemime="video/webm"] img {
  1303. border: solid #68b723 4px;
  1304. }
  1305.  
  1306. fullchan-x-gallery .gallery__close {
  1307. border: solid 1px var(--background-color)!important;
  1308. position: fixed;
  1309. top: 60px;
  1310. right: 35px;
  1311. padding: 6px 14px;
  1312. min-height: 30px;
  1313. z-index: 10;
  1314. }
  1315.  
  1316. .fcxg .gallery__scale-options {
  1317. position: fixed;
  1318. bottom: 30px;
  1319. right: 35px;
  1320. display: flex;
  1321. gap: 14px;
  1322. z-index: 10;
  1323. }
  1324.  
  1325. .fcxg .gallery__scale-options .fullchan-x__option {
  1326. border: solid 1px var(--background-color)!important;
  1327. width: 35px;
  1328. height: 35px;
  1329. font-size: 18px;
  1330. display: flex;
  1331. justify-content: center;
  1332. }
  1333.  
  1334. .gallery__main-image {
  1335. display: none;
  1336. position: fixed;
  1337. top: 0;
  1338. left: 0;
  1339. width: 100%;
  1340. height: 100%;
  1341. justify-content: center;
  1342. align-content: center;
  1343. background: rgba(0,0,0,0.5);
  1344. }
  1345.  
  1346. .gallery__main-image img {
  1347. padding: 40px 10px 15px;
  1348. height: auto;
  1349. max-width: calc(100% - 20px);
  1350. object-fit: contain;
  1351. }
  1352.  
  1353. .gallery__main-image.active {
  1354. display: flex;
  1355. }
  1356.  
  1357. /*-- Truncated file extentions --*/
  1358. .originalNameLink[data-file-ext] {
  1359. display: inline-block;
  1360. overflow: hidden;
  1361. white-space: nowrap;
  1362. text-overflow: ellipsis;
  1363. max-width: 65px;
  1364. }
  1365.  
  1366. .originalNameLink[data-file-ext]:hover {
  1367. max-width: unset;
  1368. white-space: normal;
  1369. display: inline;
  1370. }
  1371.  
  1372. a[data-file-ext]:hover:after {
  1373. content: attr(data-file-ext);
  1374. }
  1375.  
  1376. a[data-file-ext] + .file-ext {
  1377. pointer-events: none;
  1378. }
  1379.  
  1380. a[data-file-ext]:hover + .file-ext {
  1381. display: none;
  1382. }
  1383.  
  1384. /*-- Nav Board Links --*/
  1385. .nav-boards--custom {
  1386. display: flex;
  1387. gap: 3px;
  1388. }
  1389.  
  1390. #navTopBoardsSpan.hidden ~ #navBoardsSpan,
  1391. #navTopBoardsSpan.hidden ~ .nav-fade,
  1392. #navTopBoardsSpan a.hidden + span {
  1393. display: none;
  1394. }
  1395.  
  1396. /*-- Anon Unique ID posts --*/
  1397. .postInfo .spanId {
  1398. position: relative;
  1399. }
  1400.  
  1401. .fcx-id-posts {
  1402. position: absolute;
  1403. top: 0;
  1404. left: 20px;
  1405. translate: 0 calc(-100% - 5px);
  1406. display: flex;
  1407. flex-direction: column;
  1408. padding: 10px;
  1409. background: var(--background-color);
  1410. border: 1px solid var(--navbar-text-color);
  1411. width: max-content;
  1412. max-width: 500px;
  1413. max-height: 500px;
  1414. overflow: auto;
  1415. z-index: 1000;
  1416. }
  1417.  
  1418. .fcx-id-posts .nestedPost {
  1419. pointer-events: none;
  1420. width: auto;
  1421. }
  1422.  
  1423. /* mascot */
  1424. .fcx-mascot {
  1425. position: fixed;
  1426. z-index: 0;
  1427. }
  1428. `;
  1429.  
  1430. document.head.appendChild(style);
  1431.  
  1432.  
  1433. // Asuka and Eris (fantasy Asuka) are best girls