Fullchan X

16/04/2025, 18:06:52

目前為 2025-04-18 提交的版本,檢視 最新版本

// ==UserScript==
// @name        Fullchan X
// @namespace   Violentmonkey Scripts
// @match       https://8chan.moe/*/res/*.html*
// @match       https://8chan.se/*/res/*.html*
// @grant       none
// @version     1.4
// @author      vfyxe
// @description 16/04/2025, 18:06:52
// ==/UserScript==

if (!document.querySelector('.divPosts')) return;

class fullChanX extends HTMLElement {
  constructor() {
    super();
    this.enableNestedQuotes = true;
  }

  init() {
    this.quickReply = document.querySelector('#quick-reply');
    this.qrbody = document.querySelector('#qrbody');
    this.threadParent = document.querySelector('#divThreads');
    this.gallery = document.querySelector('fullchan-x-gallery');
    this.galleryButton = this.querySelector('#fcx-gallery-btn');
    this.threadId = this.threadParent.querySelector('.opCell').id;
    this.thread = this.threadParent.querySelector('.divPosts');
    this.posts = [...this.thread.querySelectorAll('.postCell')];
    this.postOrder = 'default';
    this.postOrderSelect = this.querySelector('#thread-sort');
    this.myYousLabel = this.querySelector('.my-yous__label');
    this.yousContainer = this.querySelector('#my-yous');
    this.updateYous();
    this.observers();
  }

  observers () {
    this.postOrderSelect.addEventListener('change', (event) => {
      this.postOrder = event.target.value;
      this.assignPostOrder();
    });

    const observerCallback = (mutationsList, observer) => {
      for (const mutation of mutationsList) {
        if (mutation.type === 'childList') {
          this.posts = [...this.thread.querySelectorAll('.postCell')];
          if (this.postOrder !== 'default') this.assignPostOrder();
          this.updateYous();
          this.gallery.updateGalleryImages();
        }
      }
    };

    const threadObserver = new MutationObserver(observerCallback);
    threadObserver.observe(this.thread, { childList: true, subtree: false });

    if (this.enableNestedQuotes) {
      this.thread.addEventListener('click', event => {
        this.handleClick(event);
      });
    }

    this.galleryButton.addEventListener('click', () => this.gallery.open());
  }

  handleClick (event) {
    const clicked = event.target;

    const post = clicked.closest('.innerPost') || clicked.closest('.innerOP');
    if (!post) return;

    const isNested = !!post.closest('.innerNested');
    const nestQuote = clicked.closest('.quoteLink');
    const postMedia = clicked.closest('a[data-filemime]');
    const postId = clicked.closest('.linkQuote');

    if (nestQuote) {
      event.preventDefault();
      this.nestQuote(nestQuote);
    } else if (postMedia && isNested) {
      this.handleMediaClick(event, postMedia);
    } else if (postId && isNested) {
      this.handleIdClick(postId);
    }
  }

  handleMediaClick (event, postMedia) {
    if (postMedia.dataset.filemime === "video/webm") return;
    event.preventDefault();
    const imageSrc = `${postMedia.href}`;
    const imageEl = postMedia.querySelector('img');
    if (!postMedia.dataset.thumbSrc) postMedia.dataset.thumbSrc = `${imageEl.src}`;

    const isExpanding = imageEl.src !== imageSrc;

    if (isExpanding) {
      imageEl.src = imageSrc;
      imageEl.classList
    }
    imageEl.src = isExpanding ? imageSrc : postMedia.dataset.thumbSrc;
    imageEl.classList.toggle('imgExpanded', isExpanding);
  }

  handleIdClick (postId) {
    const idNumber = '>>' + postId.textContent;
    this.quickReply.style.display = 'block';
    this.qrbody.value += idNumber + '\n';
  }

  assignPostOrder () {
    const postOrderReplies = (post) => {
      const replyCount = post.querySelectorAll('.panelBacklinks a').length;
      post.style.order = 100 - replyCount;
    }

    const postOrderCatbox = (post) => {
      const postContent = post.querySelector('.divMessage').textContent;
      const matches = postContent.match(/catbox\.moe/g);
      const catboxCount = matches ? matches.length : 0;
      post.style.order = 100 - catboxCount;
    }

    if (this.postOrder === 'default') {
      this.thread.style.display = 'block';
      return;
    }

    this.thread.style.display = 'flex';

    if (this.postOrder === 'replies') {
      this.posts.forEach(post => postOrderReplies(post));
    } else if (this.postOrder === 'catbox') {
      this.posts.forEach(post => postOrderCatbox(post));
    }
  }

  updateYous () {
    this.yous = this.posts.filter(post => post.querySelector('.quoteLink.you'));
    this.yousLinks = this.yous.map(you => {
      const youLink = document.createElement('a');
      youLink.textContent = '>>' + you.id;
      youLink.href = '#' + you.id;
      return youLink;
    })

    let hasUnseenYous = false;
    this.setUnseenYous();

    this.yousContainer.innerHTML = '';
    this.yousLinks.forEach(you => {
      const youId = you.textContent.replace('>>', '');
      if (!this.seenYous.includes(youId)) {
        you.classList.add('unseen');
        hasUnseenYous = true
      }
      this.yousContainer.appendChild(you)
    });

    this.myYousLabel.classList.toggle('unseen', hasUnseenYous);
    document.title = hasUnseenYous
      ? document.title.startsWith('🔴 ') ? document.title : `🔴 ${document.title}`
      : document.title.replace(/^🔴 /, '');
  }

  observeUnseenYou(you) {
    you.classList.add('observe-you');

    const observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const id = you.id;
          you.classList.remove('observe-you');

          if (!this.seenYous.includes(id)) {
            this.seenYous.push(id);
            localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
          }

          observer.unobserve(you);
          this.updateYous();

        }
      });
    }, { rootMargin: '0px', threshold: 0.1 });

    observer.observe(you);
  }

  setUnseenYous() {
    this.seenKey = `${this.threadId}-seen-yous`;
    this.seenYous = JSON.parse(localStorage.getItem(this.seenKey));

    if (!this.seenYous) {
      this.seenYous = [];
      localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
    }

    this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id));

    this.unseenYous.forEach(you => {
      if (!you.classList.contains('observe-you')) {
        this.observeUnseenYou(you);
      }
    });
  }

  nestQuote(quoteLink) {
    const parentPostMessage = quoteLink.closest('.divMessage');
    const quoteId = quoteLink.href.split('#')[1];
    const quotePost = document.getElementById(quoteId);
    if (!quotePost) return;

    const quotePostContent = quotePost.querySelector('.innerOP') || quotePost.querySelector('.innerPost');
    if (!quotePostContent) return;

    const existing = parentPostMessage.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`);
    if (existing) {
      existing.remove();
      return;
    }

    const wrapper = document.createElement('div');
    wrapper.classList.add('nestedPost');
    wrapper.setAttribute('data-quote-id', quoteId);

    const clone = quotePostContent.cloneNode(true);
    clone.style.whiteSpace = 'unset';
    clone.classList.add('innerNested');
    wrapper.appendChild(clone);

    parentPostMessage.appendChild(wrapper);
  }
};

window.customElements.define('fullchan-x', fullChanX);


class fullChanXGallery extends HTMLElement {
  constructor() {
    super();
  }

  init() {
    this.fullchanX = document.querySelector('fullchan-x');
    this.imageContainer = this.querySelector('.gallery__images');
    this.mainImageContainer = this.querySelector('.gallery__main-image');
    this.mainImage = this.mainImageContainer.querySelector('img');
    this.closeButton = this.querySelector('.gallery__close');
    this.listeners();
    this.addGalleryImages();
    this.initalized = true;
  }

  addGalleryImages () {
    this.thumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].map(thumb => {
      return thumb.cloneNode(true);
    });

    this.thumbs.forEach(thumb => {
      this.imageContainer.appendChild(thumb);
    });
  }

  updateGalleryImages () {
    if (!this.initalized) return;

    const newThumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].filter(thumb => {
      return !this.thumbs.find(thisThumb.href === thumb.href);
    }).map(thumb => {
      return thumb.cloneNode(true);
    });

    newThumbs.forEach(thumb => {
      this.thumbs.push(thumb);
      this.imageContainer.appendChild(thumb);
    });
  }

  listeners () {
    this.addEventListener('click', event => {
      const clicked = event.target;

      let imgLink = clicked.closest('.imgLink');
      if (imgLink?.dataset.filemime === 'video/webm') return;

      if (imgLink) {
        event.preventDefault();
        this.mainImage.src = imgLink.href;
      }


      this.mainImageContainer.classList.toggle('active', !!imgLink);

      if (clicked.closest('.gallery__close')) this.close();
    });
  }

  open () {
    if (!this.initalized) this.init();
    this.classList.add('open');
    document.body.classList.add('fct-gallery-open');
  }

  close () {
    this.classList.remove('open');
    document.body.classList.remove('fct-gallery-open');
  }
}

window.customElements.define('fullchan-x-gallery', fullChanXGallery);



// Create fullchan-x gallery
const fcxg = document.createElement('fullchan-x-gallery');
fcxg.innerHTML = `
  <div class="gallery">
    <button id="#fcxg-close" class="gallery__close fullchan-x__option">Close</button>
    <div id="#fcxg-images" class="gallery__images"></div>
    <div id="#fcxg-main-image" class="gallery__main-image">
      <img src="" />
    </div>
  </div>
`;
document.body.appendChild(fcxg);



// Create fullchan-x elemnt
const fcx = document.createElement('fullchan-x');
fcx.innerHTML = `
  <div class="fcx__controls">
    <select id="thread-sort" class="fullchan-x__option">
      <option value="default">Default</option>
      <option value="replies">Replies</option>
      <option value="catbox">Catbox</option>
    </select>

    <button id="fcx-gallery-btn" class="gallery__toggle fullchan-x__option">Gallery</button>

    <div class="fcx__my-yous">
      <p class="my-yous__label fullchan-x__option">My (You)s</p>
      <div class="my-yous__yous" id="my-yous"></div>
    </div>
  </div>
`;
document.body.appendChild(fcx);
fcx.init();



// Styles
const style = document.createElement('style');
style.innerHTML = `
  fullchan-x {
    display: block;
    position: fixed;
    top: 50px;
    right: 25px;
    padding: 10px;
    background: var(--contrast-color);
    border: 1px solid var(--navbar-text-color);
    color: var(--link-color);
    font-size: 14px;
    opacity: 0.5;
  }

  fullchan-x:hover {
    opacity: 1;
  }

  .divPosts {
    flex-direction: column;
  }

  .fcx__controls {
    display: flex;
    flex-direction: column;
    gap: 6px;
  }

  .my-yous__yous {
    display: none;
    flex-direction: column;
  }

  .fullchan-x__option {
    padding: 6px 8px;
    background: white;
    border: none !important;
    border-radius: 0.2rem;
    transition: all ease 150ms;
    cursor: pointer;
    font-size: 12px;
    font-weight: 400;
    color: #374369;
    margin: 0;
    text-align: left;
  }

  #thread-sort {
    padding-right: 0;
  }

  .fcx__my-yous:hover .my-yous__yous {
    display: flex;
    padding-top: 10px;
  }

  .innerPost:has(.quoteLink.you) {
    border-left: solid #dd003e 6px;
  }

  .innerPost:has(.youName) {
    border-left: solid #68b723 6px;
  }

  // --- Nested quotes ----
  // I don't know why it needs this, weird CSS inheritance on cloned element
  .nestedPost {}
  .divMessage .nestedPost {
    display: block;
    white-space: normal!important;
    overflow-wrap: anywhere;
    margin-top: 0.5em;
    border: 1px solid var(--navbar-text-color);
  }

  .nestedPost .innerPost,
  .nestedPost .innerOP {
    width: 100%;
  }

  .nestedPost .imgLink .imgExpanded {
    width: auto!important;
    height: auto!important;
  }

  .my-yous__label.unseen {
    background: var(--link-hover-color);
    color: white;
  }

  .my-yous__yous .unseen {
    font-weight: 900;
    color: var(--link-hover-color);
  }

  // --- Gallery ---
  .fct-gallery-open,
  body.fct-gallery-open,
  body.fct-gallery-open #mainPanel {
    overflow: hidden!important;
    position: fixed!important; //fuck you, stop scolling cunt!
  }

  body.fct-gallery-open fullchan-x {
    display: none;
  }

  fullchan-x-gallery {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    background: rgba(0,0,0,0.9);
    display: none;
    height: 100%;
    overflow: auto;
  }

  fullchan-x-gallery.open {
    display: block;
  }

  fullchan-x-gallery .gallery {
    padding: 50px 10px 0
  }

  fullchan-x-gallery .gallery__images {
    display: flex;
    width: 100%;
    height: 100%;
    justify-content: center;
    align-content: flex-start;
    gap: 4px 8px;
    flex-wrap: wrap;
  }

  fullchan-x-gallery .imgLink img {
    border: solid white 1px;
  }

  fullchan-x-gallery .imgLink[data-filemime="video/webm"] img {
    border: solid #68b723 4px;
  }

  fullchan-x-gallery .gallery__close {
    position: fixed;
    top: 60px;
    right: 35px;
    padding: 6px 14px;
    z-index: 10;
  }

  .gallery__main-image {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    justify-content: center;
    align-content: center;
    background: rgba(0,0,0,0.5);
  }

  .gallery__main-image img {
    padding: 40px 10px 15px;
    height: auto;
    max-width: calc(100% - 20px);
    object-fit: contain;
  }

  .gallery__main-image.active {
    display: flex;
  }

`;

document.head.appendChild(style);


// Asuka and Eris (fantasy Asuka) are best girls