16/04/2025, 18:06:52
当前为
// ==UserScript==
// @name Fullchan X
// @namespace Violentmonkey Scripts
// @match https://8chan.moe/*/res/*.html*
// @match https://8chan.se/*/res/*.html*
// @grant none
// @version 1.5.1
// @author vfyxe
// @description 16/04/2025, 18:06:52
// ==/UserScript==
if (!document.querySelector('.divPosts')) return;
class fullChanX extends HTMLElement {
constructor() {
super();
this.enableNestedQuotes = true;
this.enableFileExtentions = 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();
if (this.enableFileExtentions) this.handleTruncatedFilenames();
}
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();
if (this.enableFileExtentions) this.handleTruncatedFilenames();
}
}
};
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';
}
handleTruncatedFilenames () {
this.postFileNames = [...this.threadParent.querySelectorAll('.originalNameLink[download]:not([data-file-ext])')];
this.postFileNames.forEach(fileName => {
const strings = fileName.textContent.split('.');
fileName.textContent = strings[0];
fileName.dataset.fileExt = `.${strings[1]}`;
const typeEl = document.createElement('a');
typeEl.textContent = `.${strings[1]}`;
typeEl.classList = ('file-ext originalNameLink');
fileName.parentNode.insertBefore(typeEl, fileName.nextSibling);
});
}
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;
}
// Truncated file extentions
[data-file-ext] {}
.originalNameLink[data-file-ext] {
max-width: 65px;
}
a[data-file-ext]:hover:after {
content: attr(data-file-ext);
}
a[data-file-ext] + .file-ext {
pointer-events: none;
}
a[data-file-ext]:hover + .file-ext {
display: none;
}
`;
document.head.appendChild(style);
// Asuka and Eris (fantasy Asuka) are best girls