// ==UserScript== // @name Modal Image in rule(rule34图片查看器 水果玉米版) // @namespace http://tampermonkey.net/ // @version 20250119 // @description Add a modal image and thumbnails vertical-carrousel to rule34, add functions of drag img and resize img, rule34图片查看器, 修改版 // @author falaz, hzx // @match https://rule34.xxx/index.php?page=po* // @icon https://www.google.com/s2/favicons?domain=rule34.xxx // @grant GM_download // @license MIT // ==/UserScript== /*jshint esversion: 6 */ // @ts-check // original author is falaz, hzx just add functions original https://sleazyfork.org/en/scripts/390625-modal-image-in-rule\ /** * get file name from url * input: 'https://wimg.rule34.xxx//samples/2749/sample_ee25a33b3079953bab541bce5724694c.jpg?11795365' * output: 'sample_ee25a33b3079953bab541bce5724694c.jpg' * @param url * @returns {string} */ function getFileNameFromURL(url) { // 检查是否包含问号并去掉?及其后面的内容 // Check if the URL contains a question mark and remove '?' And what follows let cleanedUrl = url.includes('?') ? url.split('?')[0] : url; // 获取最后一个斜杠后的内容 // Gets the content after the last slash let fileName = cleanedUrl.substring(cleanedUrl.lastIndexOf('/') + 1); return fileName; } class Falaz { /** * Like querySelector, but small * @param {String} selector * @param {Element|Document} element * @returns {HTMLElement} */ q(selector, element = document) { return element.querySelector(selector); } /** * Like querySelectorAll, but small * @param {String} selector * @param {Element|Document} element * @returns */ qa(selector, element = document) { return element.querySelectorAll(selector); } } class Modal { constructor(document, dp) { this.pointer = 0; this.dp = dp; this.infinityScroll = new InfinityScroll(document, dp); /** * @property {Media[]} medias */ // media 构造参数传递页面的src, 就可以解析出图片的真实地址 // getMedias this.medias = this.infinityScroll.getMedias(document, 0); this.thumbsGallery = new ThumbsGallery(this.medias); this.createModalNode(document, this.thumbsGallery.render()); this.bodyParent = F.q('body'); this.visible = false; } createConfigBar() { return `` } createModalNode(document, extraDiv = null) { const div = document.createElement("div"); const css = document.createElement("style"); div.innerHTML = ``; css.innerHTML = ".content {display: flex; flex-wrap: wrap; gap: 5px;}" + "span[data-nosnippet] {display: none;}" + "#modal-container{background: #000000a8;width: 100%;height: 100%;position: fixed;z-index: 10;}" + "#modal{height: 90%;width: 80%;background: transparent;padding: 0% 5%;margin: 2% 5% 2% 0;position: fixed}" + "#modal img{width: auto;border: none;vertical-align: middle;height: 100%;margin: 0 auto;display:block;}" + "#modal video{width: 100%;height:100%}" + "#modal-container #thumbGallery{float:right;overflow-y: scroll;height: 900px;} #modal-container #thumbGallery ul{list-style:none}" + "#modal-container .gallery-item{cursor: pointer;}" + '#navigationModal{margin-bottom: 0px;font-size: 20px;color: white;display: flex;justify-content: center;bottom: 0px;width: 100%; position:absolute}' + '#navigationModal svg:hover g g {fill: white;}' + '.fluid_video_wrapper video{cursor:default!important}' + '@media (max-width: 1024px) {#thumbGallery {display: none;} #modal{width:100%;padding:0%;overflow-x:scroll}}' + '#modal-container button {border-radius: 25px; border: none; background: #1abc9c; margin-left: 10px}'; if (debug) { css.innerHTML += "img,video{filter:blur(30px)}"; } document.body.prepend(div); document.head.prepend(css); this.modalContainer = F.q("#modal-container"); this.modal = F.q("#modal"); this.infinityScroll.nextPage = this.infinityScroll.getNextPageHref(document); } /** * [important] 创建按钮, 添加功能 * [important] Create buttons & add functionality * @returns {string} */ createButtons() { return `` } /** * @param {Media} media */ async render(media) { if (!this.modalContainer) { this.createModalNode(document, this.thumbsGallery.render()); } if (media.src == "Retry") { await this.reloadMediaSrc(media); } if (media.type == "video") { this.modal.innerHTML = ``; F.q('#modalVideo').volume = this.getCurrentVolume(); F.q('#modalVideo').onvolumechange = e => this.saveCurrentVolume(e.target.volume); fluidPlayer(document.querySelector('#modal video'), { layoutControls: { autoPlay: true, allowDownload: true, loop: true, fillToContainer: true } }) F.q('#modal video').loop = true } else { this.modal.innerHTML = ``; /** * hzx 修改开始 * hzx modification BEGIN * * 添加图片的拖拽和滚轮缩放功能 * Add drag-and-drop and scroll zoom functions for images * * Button 的 onclick 事件处理函数的实现: 复位, 下载, 添加到收藏 * Implementation of onclick event handler for Button: reset, download, add to favorites */ this.innerImg = F.q("img", this.modal); let img = this.innerImg; let scale = 1; let originX = 0; let originY = 0; let isDragging = false; let startX, startY; img.addEventListener('wheel', (event) => { event.preventDefault(); const rect = img.getBoundingClientRect(); const offsetX = event.clientX - rect.left; const offsetY = event.clientY - rect.top; const delta = Math.sign(event.deltaY) * -0.1; const newScale = Math.min(Math.max(0.5, scale + delta), 3); originX = (originX - offsetX) * (newScale / scale) + offsetX; originY = (originY - offsetY) * (newScale / scale) + offsetY; scale = newScale; img.style.transform = `translate(${originX}px, ${originY}px) scale(${scale})`; }); img.addEventListener('mousedown', (event) => { event.preventDefault(); isDragging = true; startX = event.clientX; startY = event.clientY; img.style.cursor = 'grabbing'; }); window.addEventListener('mouseup', () => { event.preventDefault(); isDragging = false; img.style.cursor = 'grab'; }); window.addEventListener('mousemove', (event) => { event.preventDefault(); if (!isDragging) return; const dx = event.clientX - startX; const dy = event.clientY - startY; originX += dx; originY += dy; startX = event.clientX; startY = event.clientY; img.style.transform = `translate(${originX}px, ${originY}px) scale(${scale})`; }); this.resetImage = function () { scale = 1; originX = 0; originY = 0; img.style.transform = `translate(0, 0) scale(${scale})`; } // hzx 修改结束 // hzx modification END } this.modalContainer.style.display = "block"; this.visible = true; this.toggleBodyScroll(false); modalObj.scrollIntoCurrentMedia(); } /** * hzx 修改开始 * 收藏, 下载功能 * hzx modification BEGIN */ favorite() { function apiAddFav(id) { return fetch(`https://rule34.xxx/public/addfav.php?id=${id}`); } async function addFav(id) { let resp = await apiAddFav(id); let content = await resp.text(); debugger; if (content !== '') { showToast("[Success] 收藏成功"); } else { showToast("[fail] 收藏失败"); } } /** * input: http://www.xxx.com?123333 * output: 123333 * @param url * @returns {*|null} */ function getUrlId(url) { const parts = url.split('?'); return parts.length > 1 ? parts[1] : null; } addFav(getUrlId(this.medias[this.pointer].src)); } /** * 2025/01/19: * [bug fix] 修复无法下载视频的bug * [bug fix] Fixed a bug where videos could not be downloaded */ download() { showToast("下载开始, 请等待\ndownload begin, plz wait") let src = this.medias[this.pointer].src; GM_download({url: src, name: getFileNameFromURL(src)}); } // hzx 修改结束 // hzx modification END toggleBodyScroll(visible) { this.bodyParent.style.overflow = visible ? 'inherit' : 'hidden'; } getCurrentVolume() { return localStorage.volume ? localStorage.volume : 0.0; } /** * Attach to event video.onvolumechange * @param {number} value */ saveCurrentVolume(value) { localStorage.volume = value; } async getNextPage() { if (!this.infinityScroll.nextSended) { this.infinityScroll.nextSended = true; const docResponse = await this.infinityScroll.getNextPage(); if (docResponse) { const medias = this.infinityScroll.getMedias( docResponse, this.medias.length ); this.addMedias(medias); this.infinityScroll.nextSended = false; } else { c('The page requested is on the medias'); } } } close() { if (!this.modalContainer) { this.createModalNode(document); } try { this.modal.querySelector("video").pause(); } catch (e) { } this.modalContainer.style.display = "none"; this.toggleBodyScroll(true); this.visible = false; } nextMedia() { if (this.pointer < this.medias.length - 1) { this.pointer++; this.render(this.medias[this.pointer]); } else { this.getNextPage(); this.pointer++; this.render(this.medias[this.pointer]); } } goToMedia(index) { if (index < this.medias.length - 1) { this.pointer = index; this.render(this.medias[index]) } } prevMedia() { if (this.pointer > 0) { this.pointer--; this.render(this.medias[this.pointer]); } else { console.error("Reach the start of the medias"); } } getCurrentMedia() { this.medias[this.pointer]; } getCurrentThumb() { return F.q(`span [data-index="${this.pointer}"]`).parentElement } /** * * @param {Media[]} medias */ addMedias(medias) { const mediasTemp = [...this.medias, ...medias]; // @ts-ignore this.medias = mediasTemp; this.thumbsGallery.updateMedias(mediasTemp); } async reloadMediaSrcFromMedias(index) { await this.medias[index].reloadSrc(); } async reloadMediaSrc(media) { await media.reloadSrc(); } scrollIntoCurrentMedia() { c('fired') this.getCurrentThumb().scrollIntoView(); this.thumbsGallery.scrollToThumb(modalObj.pointer) } } // 获取页面图片的真实地址, 并保存 class Media { /** * @param {string} _page This is the page of the media. Not the real image src. * @param {string} _type * @param {string} _thumb */ constructor(_page, _type, _thumb, dp) { this.page = _page; this.type = _type; this.thumb = _thumb; this.dp = dp; this.getSrc().then((src) => { this.src = src; }); } async getSrc() { const response = await fetch(this.page); if ([202, 200].includes(response.status)) { const body = await response.text(); const dp = new DOMParser(); const pageDocument = dp.parseFromString(body, "text/html"); const video = F.q("video source", pageDocument); const image = F.q(".flexi img", pageDocument); if (video) { this.type = "video"; // @ts-ignore return video.src; } else { this.type = "image"; // @ts-ignore return image.src; } } else { return "Retry"; } } async reloadSrc() { this.src = await this.getSrc(); } } // 实现无线滚动 class InfinityScroll { /** * @param {Document} document * @param {DOMParser} _dp */ constructor(document, _dp) { this.nextSended = false; this.pagesParsed = [this.getPageNumber(document)]; this.nextPage = this.getNextPageHref(document); this.dp = _dp; } /** * @param {Document} doc */ getPageNumber(doc) { // get current page number from one page return parseInt(doc.querySelector("#paginator div b").innerHTML); } /** * @param {Document} doc */ getNextPageHref(doc) { const nextNode = doc.querySelector('#paginator [alt="next"]'); // @ts-ignore return nextNode ? nextNode.href : null; } async getNextPage() { c("getNextPage"); if (this.nextPage === null) { // @ts-ignore this.nextPage = F.q('#paginator [alt="next"]').href; } const response = await fetch(this.nextPage); if ([204, 202, 200, 201].includes(response.status)) { const body = await response.text(); const dp = new DOMParser(); const pageDocument = dp.parseFromString(body, "text/html"); const pageNum = this.getPageNumber(pageDocument); this.nextPage = this.getNextPageHref(pageDocument); if (!this.pagesParsed.includes(pageNum)) { // remove the page allready parsed this.pagesParsed.push(pageNum); return pageDocument; } else { return false; } } else { await this.sleep(500); return await this.getNextPage(); } } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * 获取所有缩略图, 并设置onclick事件 * Gets all the thumbnail elements and sets the onclick event * @param {Document} document * @param {number} pad The length of medias in the Modal Object * @returns {Media[]} */ getMedias(document, pad) { // .thumb 作为类名通常是指 "thumbnail"(缩略图)的简称 const thumbsNode = F.qa("#content .thumb", document); const medias = []; const nodes = []; //const pad = this.medias? this.medias.length : 0; for (let i = 0; i < thumbsNode.length; i++) { const node = thumbsNode[i]; /** * @type {HTMLImageElement} img */ // @ts-ignore const img = F.q("img", node); const title = img.title; const anchor = node.querySelector("a"); const media = new Media( anchor.href, /animated|video/.test(title) ? "video" : "image", img.src, this.dp ); anchor.dataset.index = (i + pad).toString(); img.dataset.index = (i + pad).toString(); //node.dataset.index = (i+pad).toString(); medias.push(media); nodes.push(node); } this.addElementsToCurrentContentView(nodes); // async return medias; } async addElementsToCurrentContentView(nodes) { for (const node of nodes) { this.clickFunction(node); F.q(".content").insertBefore(node, F.q("#paginator")); } } clickFunction(element) { /** * hzx 修改开始 * hzx modification BEGIN * * 2025/01/19: * [bug fix] 多音频, 关闭视频后, 仍有音频的问题 * [bug fix] fix multiple audio problem */ element.onclick = (e) => { e.preventDefault(); const index = e.target.dataset.index ? e.target.dataset.index : modalObj.pointer; modalObj.pointer = index; modalObj.render(modalObj.medias[index]); }; // hzx 修改结束 // hzx modification END } } class ThumbsGallery { /** * @param {Media[]} medias */ constructor(medias) { this.medias = medias; } /** * @param {number} index */ scrollToThumb(index) { F.q(`.gallery-item[data-index="${index}"]`).scrollIntoView(); } /** * @param {Media[]} medias */ updateMedias(medias) { this.medias = medias; F.q('#thumbGallery').innerHTML = ``; } render() { return `
` } buildItems() { let li = '' this.medias.forEach((e, i) => { li += `` }) return li; } } const dp = new DOMParser(); const F = new Falaz(); let modalObj; const loadingSVG = "https://samherbert.net/svg-loaders/svg-loaders/puff.svg"; const debug = false; const c = (...e) => { if (debug) { console.log(e); } } (function () { "use strict"; modalObj = new Modal(document, dp); if (debug) { document.title = 'New Tab' } // @ts-ignore document.modalObj = modalObj; F.qa(".content .thumb").forEach((element) => { modalObj.infinityScroll.clickFunction(element); }); document.addEventListener("keydown", (e) => { if (e.key == "ArrowRight") { modalObj.nextMedia(); modalObj.scrollIntoCurrentMedia(); } else if (e.key == "ArrowLeft") { modalObj.prevMedia(); modalObj.scrollIntoCurrentMedia(); } else if (e.key == "Escape") { modalObj.close(); } }); // @ts-ignore document.addEventListener("scroll", (e) => { if (!modalObj.visible) { const inner = window.innerHeight; if ( window.scrollY + inner > document.documentElement.scrollHeight - inner * 2 ) { modalObj.getNextPage(); } } else { e.preventDefault(); modalObj.scrollIntoCurrentMedia(); } }); // queryAsync(".awesomplete input").then(inputEl => { // if (!inputEl.value.includes("-ai_generated")) { // setTimeout(() => { // inputEl.value = inputEl.value += " -ai_generated"; // queryAsync("input[type=submit]").then(btnEl => btnEl.click()); // }, 500); // } // }); })(); /** * 类似于querySelector, 但是异步, 并且不会报错, 不会导致脚本停止执行 * like querySelector, but async, and never throw error to causes the script to stop executing * @param selector * 选择器, 和querySelector的参数一致 * selector, same to the param of querySelector * @param timeout * timeout 为-1时 无限查询并等待 * If timeout is -1, query indefinitely and wait * @param onError * 回调函数, 在查找不到时执行 * callback function, it will be called when the query fails * @param isBlocking * 在查询失败时, 是否保持Promise为pending状态, 默认为false * whether to keep the Promise in pending state when a query fails. The default is false * @returns {Promise} */ function queryAsync(selector, timeout = 1000, onError = null, isBlocking = false) { let errEl = document.body.errEl; if (errEl === undefined) { errEl = document.createElement('div'); errEl.classList.add('no-found'); errEl.remove = () => {}; document.body.errEl = errEl; } // 这里不用reject是担心脚本报错后, 终止运行 // We don't use reject because it will throw error and case the script to stop executing return new Promise((resolve) => { const startTime = Date.now(); function check() { const ele = document.querySelector(selector); if (ele !== null) { resolve(ele); // 找到元素,结束 Promise return; } if (timeout !== -1 && Date.now() - startTime > timeout) { if (onError !== null) { onError(); } if (!isBlocking) { resolve(errEl); } console.warn(`$Q_Async: Timeout: Cannot find element for selector "${selector}"`); return; } setTimeout(check, 100); } check(); }); } function showToast(message) { let toast = document.body.toast; if (toast === undefined) { toast = document.createElement('div'); toast.style.position = 'fixed'; toast.style.bottom = '55vh'; toast.style.left = '50%'; toast.style.transform = 'translateX(-50%)'; toast.style.padding = '10px 20px'; toast.style.backgroundColor = '#333'; toast.style.color = '#fff'; toast.style.borderRadius = '5px'; toast.style.fontSize = '14px'; toast.style.zIndex = '9999'; toast.style.opacity = '0'; toast.style.transition = 'all 0.5s ease'; toast.style.visibility = 'hidden'; document.body.appendChild(toast); document.body.toast = toast; } toast.innerText = message; // 显示 toast toast.style.opacity = '1'; toast.style.visibility = 'visible'; // 3秒后隐藏 setTimeout(() => { toast.style.opacity = '0'; toast.style.visibility = 'hidden'; }, 3000); }