您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Sankaku Complex (Idol) Gallery Overlay
// ==UserScript== // @name Galleria // @version 0.3.1 // @description Sankaku Complex (Idol) Gallery Overlay // @homepage https://github.com/agony-central/Galleria // @supportURL https://github.com/agony-central/Galleria/issues // @author agony_central // @include /^https?://idol.sankakucomplex.com/?(\?[^/]+)?$/ // @require https://code.jquery.com/jquery-3.6.2.slim.js // @require https://unpkg.com/react@18/umd/react.production.min.js // @require https://unpkg.com/react-dom@18/umd/react-dom.production.min.js // @icon https://idol.sankakucomplex.com/favicon.png // @grant unsafeWindow // @namespace https://greasyfork.org/users/1000369 // ==/UserScript== (async () => { const LOG_PREFIX = '[Galleria]'; const LOG_LEVEL = 1; const print = (level, ...args) => { if (level <= LOG_LEVEL) console.info(LOG_PREFIX, ...args); }; const RESOURCE_PREFIX = '__GALLERIA_'; const rn = (name) => RESOURCE_PREFIX + name; const FETCH_MEDIA_RATE_LIMIT = 5000; const CHECK_NEW_POST_INTERVAL = 1800; const CHECK_NEW_PAGE_INTERVAL = 5000; const LOOKAHEAD_POST_COUNT = 32; const COMMON_CLASSNAME_POST = 'thumb'; const { $ } = window; const { React } = window; const { ReactDOM } = window; const e = React.createElement; /** * * The page we are on stores all posts in "#content > .content" * however, the page has infinite scroll pagination. * * There are three details to note about the pagination: * * First: the post storage ("#content > .content") stores the * first page as a direct child div, with no ID. * * Second: the rest of the pages are stored as siblings of * the first page, with IDs of the form "content-page-n" where * `n` is the page number (NOT zero-indexed, starts at 2 since the * first page has no ID). * * Third: the first page's first element is a grouped, promotional * post with the ID "popular-preview", which we will ignore. * * ================================================================= * * Galleria is concerned with two things: * * [UI] overlaying a gallery on the page, that can be: * a. toggled on and off * b. navigated easily * * [LOADER] keeping an actively loaded list of posts, by: * a. scrolling to the bottom of the page when appropriate * 1. when the number of remaining posts ahead of the * currently viewed post is less than LOOKAHEAD_POST_COUNT * b. loading the media of posts as they are encountered * 1. creating an iframe to the post's page * 2. extracting the media from the iframe * 3. storing the media in the post's data * * ================================================================= */ /** * globalStore * * An object that manages the current state of the UI and LOADER * components of Galleria. */ const globalStore = { ui: { // whether the React app is currently mounted mounted: false, // whether the gallery is currently visible visible: false, // index of the currently viewed post currentPostIndex: 0, }, loader: { // last encountered page number lastPage: 0, // all posts encountered so far posts: [], // last time a post's media was loaded lastFetch: 0, }, }; const ref_elem_Content = $('#content .content'); // const ref_elem_GalleryMediaStore = $( // `<div id="${rn('media-store')}"></div>` // ); const ref_elem_GalleryRoot = $(`<div id="${rn('root')}"></div>`).appendTo( 'body' ); /** * Current post React component */ class CurrentPost extends React.Component { constructor(props) { super(props); this.iframeRef = React.createRef(); this.pruneIframe = this.pruneIframe.bind(this); } render() { return e('iframe', { ref: this.iframeRef, src: this.props.data.link, style: { boxSizing: 'border-box', width: '100%', height: '100%', margin: 0, padding: 0, border: 0, }, onLoad: this.pruneIframe, }); } pruneIframe() { // $(this.iframeRef.current.contentDocument) // .contents() // .find('*') // .not('#image') // .remove(); } } /** * Post preview (thumbnail) React component */ class PostPreview extends React.Component { state = { hover: false, }; constructor(props) { super(props); } render() { return e( 'div', { key: this.props.id, index: this.props.index, style: { position: 'relative', boxSizing: 'border-box', width: '100%', height: '200px', margin: 0, padding: 0, backgroundColor: this.props.active ? 'white' : 'black', }, onClick: () => this.props.handleClick(this.props.index), onMouseEnter: () => this.setState({ hover: true }), onMouseLeave: () => this.setState({ hover: false }), }, [ e('img', { src: this.props.src, style: { width: '100%', height: '100%', margin: 0, padding: 0, objectFit: this.state.hover ? 'contain' : 'cover', }, }), this.props.active ? e('div', { style: { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(0, 0, 0, 0.7)', }, }) : undefined, ] ); } } /** * Sidebar React component of loaded posts */ class PostList extends React.Component { constructor(props) { super(props); this.ref_elem_Sidebar = React.createRef(); } render() { return e( 'div', { ref: this.ref_elem_Sidebar, style: { display: 'inline-block', boxSizing: 'border-box', overflow: 'hidden scroll', width: '100%', height: '100%', margin: 0, padding: 0, }, }, [ this.props.data.map((post, index) => e(PostPreview, { id: post.id, index, src: post.thumbnail, active: index == this.props.currentPostIndex, handleClick: this.props.handleClick, }) ), ] ); } } /** * Root React component */ class Galleria extends React.Component { constructor(props) { super(props); this.state = { ...props.globalStore }; this.syncGlobalStore = this.syncGlobalStore.bind(this); this.toggleGallery = this.toggleGallery.bind(this); this.updateCurrentPostIndex = this.updateCurrentPostIndex.bind(this); this.viewNextPost = this.viewNextPost.bind(this); this.viewPreviousPost = this.viewPreviousPost.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); } componentWillMount() { this.props.ensureMinimumPosts(); document.addEventListener('keydown', this.handleKeyDown); } componentDidMount() { globalStore.ui.mounted = true; this.syncGlobalStore(); } componentWillUnmount() { globalStore.ui.mounted = false; document.removeEventListener('keydown', this.handleKeyDown); } syncGlobalStore() { this.setState({ ...this.props.globalStore }); } toggleGallery(force = null) { if (force !== null) { globalStore.ui.visible = force; } else { globalStore.ui.visible = !globalStore.ui.visible; } this.syncGlobalStore(); } updateCurrentPostIndex(newPostIndex) { globalStore.ui.currentPostIndex = newPostIndex; this.syncGlobalStore(); this.props.ensureMinimumPosts(); } viewNextPost() { const nextPostIndex = this.state.ui.currentPostIndex + 1; if (nextPostIndex >= this.state.loader.posts.length) return; this.updateCurrentPostIndex(nextPostIndex); } viewPreviousPost() { const previousPostIndex = this.state.ui.currentPostIndex - 1; if (previousPostIndex < 0) return; this.updateCurrentPostIndex(previousPostIndex); } handleKeyDown(e) { switch (e.key) { case 'g': case 'f': this.toggleGallery(); break; case 'Escape': this.toggleGallery(false); break; case 'd': case 's': case 'ArrowDown': case 'ArrowRight': this.viewNextPost(); break; case 'a': case 'w': case 'ArrowUp': case 'ArrowLeft': this.viewPreviousPost(); break; } } render() { window.document.body.style.overflowY = this.state.ui.visible ? 'hidden' : 'scroll'; return e('section', {}, [ this.state.ui.visible ? e( 'div', { style: { position: 'fixed', top: 0, left: 0, width: '100vw', height: 'calc(100vh - 50px)', zIndex: 10001, display: 'grid', gridTemplateColumns: '1fr 4fr', backgroundColor: 'black', color: 'white', }, }, [ e(PostList, { data: this.state.loader.posts, currentPostIndex: this.state.ui.currentPostIndex, handleClick: this.updateCurrentPostIndex, }), e(CurrentPost, { index: this.state.ui.currentPostIndex, data: this.state.loader.posts[ this.state.ui.currentPostIndex ], loadPost: () => this.props.loadPostMediaByIndex( this.state.ui.currentPostIndex ), }), ] ) : null, e( 'button', { onClick: this.toggleGallery, style: { boxSizing: 'content-box', width: '100%', height: '50px', margin: 0, position: 'fixed', left: 0, bottom: 0, zIndex: 10002, border: 0, backgroundColor: 'black', color: 'white', }, }, 'Toggle Gallery' ), ]); } } async function loadNewPosts(newPosts) { let postsLoaded = 0; for (let i = 0; i < newPosts.length; i++) { const post = newPosts[i]; /** * Skip elements that don't have the class name * `COMMON_CLASSNAME_POST`. */ if (!post.classList.contains(COMMON_CLASSNAME_POST)) continue; /** * Posts, as they are loaded, start out with an inline style * of "display: none;" — we iterate over each post, waiting * for the display to no longer be "none", and then we add * its ID, link, and thumbnail as an object to * `globalStore.loader.posts`. * * This is technically unnecessary, since the data we need * is already available in the DOM, but it is a good * idea to wait for the post to be fully loaded before * adding it to the globalStore. */ while (post.style.display === 'none') { await new Promise((r) => setTimeout(r, CHECK_NEW_POST_INTERVAL) ); } globalStore.loader.posts.push({ id: post.id.substring(1), link: post.children[0].href, thumbnail: post.children[0].children[0].src, }); if (globalStore.ui.mounted) __GALLERIA.syncGlobalStore(); postsLoaded++; } print(2, `Loaded ${postsLoaded} new posts.`); } async function parseNewPages() { const lastPageInDOM = ref_elem_Content.find('.content-page').last(); const lastPageNumber = lastPageInDOM.length ? parseInt(lastPageInDOM.attr('id').split('-')[2]) : 1; print(2, 'Last page in DOM:', lastPageNumber); if (lastPageNumber == 1) return; /** * Iterate over any new pages, loading their posts. */ for ( let idx = globalStore.loader.lastPage + 1; idx <= lastPageNumber; idx++ ) { const pageID = `#content-page-${idx}`; print(2, `Parsing page "${pageID}" [${idx}/${lastPageNumber}]...`); const newPosts = $(pageID).children(); await loadNewPosts(newPosts); globalStore.loader.lastPage = idx; } } /** * This function is called repeatedly to check if new posts * need to be loaded, and if so, updates the globalStore. */ async function ensureMinimumPosts() { print(2, 'Ensuring minimum posts...'); /** * If the `globalStore.loader.lastPage` is 0, then we have not * yet initialized — so we load the first page while ignoring * the first post (the promotional post). */ if (globalStore.loader.lastPage === 0) { print(2, 'Loading first page...'); const newPosts = ref_elem_Content .children() .first() .children() .slice(1); await loadNewPosts(newPosts); globalStore.loader.lastPage = 1; } let prevPostCount = globalStore.loader.posts.length; while ( prevPostCount - globalStore.ui.currentPostIndex < LOOKAHEAD_POST_COUNT ) { print(2, 'Scrolling to next "page" for more posts...'); unsafeWindow.scrollTo(0, unsafeWindow.document.body.scrollHeight); // wait a split second to let the page load await new Promise((r) => setTimeout(r, 450)); await parseNewPages(); if (prevPostCount === globalStore.loader.posts.length) { // should have waited longer print( 1, 'Haven\'t found new page yet, waiting before trying again...' ); await new Promise((r) => setTimeout(r, CHECK_NEW_PAGE_INTERVAL) ); } else { prevPostCount = globalStore.loader.posts.length; } } } async function loadPostMediaByIndex(index) { if ( Date.now() - globalStore.loader.lastMediaLoad < FETCH_MEDIA_RATE_LIMIT ) { print(2, 'Media load rate limit exceeded'); const timeRemaining = FETCH_MEDIA_RATE_LIMIT - (Date.now() - globalStore.loader.lastMediaLoad); await new Promise((r) => setTimeout(r, timeRemaining)); } const post = globalStore.loader.posts[index]; if (!post) { print(1, 'No post found for index', index); return; } if (post.fullMedia) { print(2, 'Media already loaded for post', index); return; } print(2, 'Loading media for post', index); // use jquery to create iframe to post.link // then use jquery to find media in iframe // then use jquery to remove iframe const iframe = $(`<iframe src="${post.link}"></iframe>`); iframe.css('display', 'none'); $('body').append(iframe); let iframeLoading = true; iframe.on('load', () => { iframeLoading = false; }); print(2, 'Waiting for iframe to load...'); await new Promise((r) => setTimeout(r, 2000)); while (iframeLoading) { print(2, 'Still waiting for iframe to load...'); await new Promise((r) => setTimeout(r, 2000)); } const media = iframe.contents().find('#image')[0]; print(2, 'iframe loaded with media:', media); post.fullMedia = iframe; globalStore.loader.lastMediaLoad = Date.now(); if (globalStore.ui.mounted) __GALLERIA.syncGlobalStore(); } /** * Initialize React application */ const __GALLERIA = ReactDOM.render( e(Galleria, { globalStore, ensureMinimumPosts, loadPostMediaByIndex, }), ref_elem_GalleryRoot[0] ); })();