Eza's Gallery Swallower

Turn a page of thumbnails into high-res images

As of 24. 04. 2021. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        Eza's Gallery Swallower
// @namespace   https://inkbunny.net/ezalias
// @description Turn a page of thumbnails into high-res images
// @license     MIT
// @license     Public domain / no rights reserved

// @include     https://www.pixiv.net/en/users/*
// @include     https://www.pixiv.net/bookmark_new_illust.php*
// @include     https://www.pixiv.net/en/tags/*/artworks*
// @include     https://www.pixiv.net/user/*/series/*

// @include     https://gelbooru.com/index.php?page*
// @include     https://safebooru.org/index.php?page*

// @include     https://e621.net/posts*
// @include     https://e926.net/posts*
// @include     https://e621.net/pools/*
// @include     https://e926.net/pools/*

// @include     https://*.booru.org/*s=list*

// @include     /^https?\:\/\/www\.hentai-foundry\.com\//

// @include     https://baraag.net/@*
// @include     https://botsin.space/@*
// @include     https://equestria.social/@*
// @include     https://mastodon.art/@*
// @include     https://mastodon.social/@*
// @include     https://pawoo.net/@*

// @include     /^https?\:\/\/rule34\.paheal\.net\//

// @include     https://derpibooru.org/*

// @include     https://inkbunny.net/*
// @exclude    https://inkbunny.net/s/*

// @include     https://www.furaffinity.net/*

// @include     https://danbooru.donmai.us/*

// @include     https://rule34.xxx/*

// @include     https://www.jabarchives.com/*
// @include     https://jabarchives.com/*

// @include     https://aryion.com/g4/view/*
// @include     https://aryion.com/g4/gallery/*

// @include     https://e-hentai.org/g/*

// @include     https://lolibooru.moe/*
// @include     https://yande.re/*

// @include     https://thehentaiworld.com/*

// @exclude    *#dnr
// @noframes
// @version     2.19.61
// @grant        GM_registerMenuCommand
// ==/UserScript==



// Create a vertical view for high-res versions of all the image links you can see. 
// This includes all pages from multi-image submissions, where those exist.
// Individual images (or whole submissions) can be removed with one click. 
// Each image is also a link to that image. 

// User options:
var default_size = "short"; 			// Choices: short, fit_width, fit_height, fit_window, full. 
var new_image_rate = 50; 			// Milliseconds between loading images. E.g. 10 for instant, 200 for gradual. 
var fetch_rate = 250; 					// Slower rate for fetching than for loading images, to avoid rate-limiting. 
var top_to_bottom = false; 			// Default order. True: as seen on the page. False: reversed, which is usually chronological. 
var keyboard_controls = true; 		// Set to false if you don't want WASD / QERX / Ctrl+Z to do anything. 
var low_opacity = false; 				// See-through buttons. 



// Table of contents:
	// Preamble
		// CSS
		// HTML
		// Helper functions
		// Polyfills
	// Main execution 
		// Per-site image-gathering functions
		// The Button 
	// Show images 
		// Per-submission setup
		// Keyboard controls
		// Ongoing image-display function
		// Spinners

// Other Mastodon instances are many in number and few in users. An @include block would get ridiculous. 
// I am alarmingly close to forking this for Mastodon, doing @include *, and testing for e.g. h-cite / h-entry. 
	// I don't want to @include * in general - this is not a general-purpose script. 
	// Even a fetch-based version would only be as flexible as Image Glutton. 
	// ... I've accidentally made this @include * friendly, by exclusively using button_delay_function. 
		// If it defaulted to false I could use this anywhere, and only specific switch-case / match domains would get better values. 
		// Or I could skip the interval entirely if button_delay_function remains at a default null. 
			// (Probably better to do so if gather_items remains at a default null.) 

// HicceArs? 
// Youhate.us? Down. 
// DeviantArt? Tried it once, didn't really work. Probably needs a fetch. Even that's not consistent. It is a terrible website. 

// Might move image count to the controls area. 
	// Its text already implies "image size" and "image order." But mostly I'm bugged that it doesn't line up with other text.
	// Just line it up sensibly. 

// It is annoyingly easy to get caught clicking remove-and-advance instead of just 'advance.' 
	// The button snaps into place under your cursor if you're submission-aligned and hit 'next image.' 
	// It's a problem mostly because the apparent result is the same - we scroll clean past the removed image. 
	// Keyboard controls have made this a non-issue for me. Dunno how others use the script. 

// Tumblr? Not a -great- alternative to Eza's Tumblr Scrape, but pretty close to what that script originally wanted. 

// Still iffy about title text on largely pictographic buttons. 
	// I guess I can't say the script is without language, when the big green button says "swallow gallery." 

// Can I trigger enforce_style on updates? this.parentElement, presumably, but still. 



	// To do:

// Videos. 
	// Videos work, but:
	// No hotkey to pause / unpause. (Picked F.) 
	// Muted-by-default is staying, but should that get a hotkey too? Or a big global toggle switch? 
		// Mute / Autoplay buttons alongside the dark mode button?
	// I need a link somewhere, and the sorta tablet-y design I've chosen says it shouldn't be a thin bit of text. 
		// Added sensible links for DownThemAll, but they're not available for a human to click on. Later. 
	// Possible alternative: on video.canplay, remove the video, and link to the source. 
	// Ogg in video_formats?  
	// Remove-and-undo leaves videos paused. Dunno if that's a problem to be fixed or a happy accident. Shrug. 
	// Goofy thought from prior notes: check tags? 

// I remain iffy on whether videos are a good idea, because this is going to significantly increase bandwidth use. 
	// ... but it's not worse I was already doing with Eza's Image Glutton and a shitload of tabs. 

// Maximum images loading at once? Not a slower hit rate, but a simultaneity limit. 

// May use C as 'remove submission and advance." No associated button. Going E/D/E/D is goofy. 



	// Genuine bugs:

// Dead images and possibly thumbnails take up horizontal space and cause slight movements while loading. 

// Keyboard controls often can't remove the last image. Eh. (Could give it 100vh of runout. Add some visual fluff down there, like a dotted line.) 
	// Bottom anchor once again does not extend "dark mode" div to the bottom of the... page? window? There's whitespace, is the point. 
	// Do fix these. They're annoying. 

// Link text (i.e. submission number, #dnr&dnr) isn't vertically centered with buttons. Not looking forward to fixing that. 
	// Spinners, image count, and size-control buttons really ought to be aligned, and use the same text size. 



// Changes since last version: 
	// Added video support - conditional on image formats failing. That's probably a mistake because some sites use matching "poster" URLs. 
	// Videos now remove themselves and clean up the image_container if they're the last thing removed. Still have image+video together. Hmm. 
	// Videos now start .invisible (display:none). 
	// Videos now remove the matching image when they begin loading (canplay). 
		// Videos now reliably remove their matching image by resetting time to zero when reloaded / remove+undo'd. 
		// Videos now reliably remove their matching image without causing an infinite event loop of currentTime -> canplay -> currentTime -> canplay. 
	// Gave videos a download link so DownThemAll isn't guessing and making up filenames. 
		// Gave video download links a download name so DownThemAll uses the original goddamn filename. 
		// Made link invisible because I can't be arsed to fight the layour right now. 
	// Made videos autoplay when they come onscreen and autopause when they go offscreen. 
		// Made videos respect manual pausing by only autoplaying when it's the offscreen check that first pauses them. 





// ------------------------------------ Custom replacement HTML ------------------------------------ //





// Replacement page. Not used immediately; it just makes more sense up here. 
var html = '';
var style_rules = new Object; 			// CSS "selector": "style" map. Blame Baraag. 



	// ----- //			CSS



// CSS rules as an associative array, so they can be applied per-element on uncooperative sites. 

// Image style(s):
style_rules[ ".short img, .short video" ] = "max-width: 90vw; max-height: 60vh; vertical-align: middle;"; 		// Class is for <body>. 
style_rules[ ".full img, .full video" ] = "max-width: initial; max-height: initial; vertical-align: middle;"; 			// "initial" for enforce_style(). 
style_rules[ ".fit_width img, .fit_width video" ] = "max-width: 90vw; max-height: initial; vertical-align: middle;"; 	// Leaving space for big X.
style_rules[ ".fit_height img, .fit_height video" ] = "max-width: initial; max-height: 95vh; vertical-align: middle;"; 
style_rules[ ".fit_window img, .fit_window video" ] = "max-width: 90vw; max-height: 95vh;vertical-align: middle;"; 

// Dead spinners and other invisible elements:
style_rules[ '.invisible' ] = 'display: none;'; 		// s0 for spinners: className is s+querySelectorAll.length. 
style_rules[ '.button_spacer' ] = 'visibility: hidden; width: 30px !important; height: 30px;'; 

style_rules[ '#image_counter_id' ] = 'position: absolute; left: 70px; top: 5px; font-size: 33px;';

// Remove / reload controls: 
style_rules[ '.remover' ] = 'color:#dd2e44 !important; background-color:#992a2a; border:1px solid #ab1919;';
style_rules[ '.remover.floating' ] = 'position: absolute; top: 50%; transform: translateY(-50%); width: 120px; height: 120px; font-size:72px !important;';
style_rules[ '.reloader' ] = 'color:#194d19 !important; background-color:#2abd2a; border:1px solid #19ab19;';
style_rules[ '.undo' ] = 'position: fixed; right: 0px; bottom: 0px;'; 

// Previous / next buttons, per-submission:
style_rules[ '.nav_button' ] = 'color:#123 !important; background-color:#14f; border:1px solid #234;';
style_rules[ '.nav_float' ] = 'z-index: 11; position: absolute; left: 0px; top: initial;'; 
style_rules[ '.nav_next_image' ] = 'top: 72px;'; 
style_rules[ '.remover.nav_button' ] = 'top: 144px; font-size: 20px !important; color: #a12 !important; background-color:#dd2e44;'; 		// Remove-and-advance. 

style_rules[ '.image_container.submission' ] = 'position: absolute; height: 300px; visibility: hidden;'; 		// display:none breaks scrollIntoView. 

// Button DRY:
style_rules[ '.eza_button' ] = 'border-radius: 50%; width: 60px; height: 60px; text-align: center; display: inline-block; cursor:pointer; line-height: 20px; font-size:33px !important; padding: 10px 10px; text-decoration: none; font-family: sans-serif !important;';
style_rules[ '.eza_button:not(:hover)' ] = 'background-color:#d7dbd8;' 		// Inverts and elides repeated .nav:hover / .reloader:hover background-color rules. 
//style_rules[ '.dark .eza_button:not(:hover)' ] = 'background-color:#000;' 		// Not a great idea. The blue arrows would need to be much brightner, for a start. 
	// Really, just make translucent buttons the default. It's so much better. 

if( low_opacity ) { style_rules[ '.eza_button' ] += ' opacity: 0.3;'; style_rules[ '.eza_button:hover' ] = 'opacity: 1.0;'; }		// Maybe. 

// enforce_style kludges: 
style_rules[ '.floating.remover' ] = 'width: 120px; height: 120px; font-size:72px !important;';
style_rules[ '.nav_button.remover' ] = 'font-size: 20px !important;';

// Image-size controls:
style_rules[ '#controls_id' ] = 'float: right; font-size: 33px;';
style_rules[ '.eza_button.control_button' ] = 'color:#FFF; background-color:#363;'; 		// Overdefined to supercede :not(:hover). 
style_rules[ '.control_button:hover' ] = 'background-color:#282;'; 
style_rules[ '.short #short, .full #full, .fit_width #fit_width, .fit_height #fit_height, .fit_window #fit_window' ] = 'color:#FFF; background-color:#191;'; 

// Dark mode button:
style_rules[ '#backdrop_id' ] = 'background: rgba(0,0,0,0)'; 		// For enforce_style. 
style_rules[ '#backdrop_id.dark' ] = 'background: #181818'; 
style_rules[ '.dark #dark_mode_button' ] = 'color: #000 !important;'

// Previous / next links, at the bottom:
style_rules[ '#links_id' ] = 'font-size: 33px; !important';

// Thumbnail container and fixed-size thumbnails:
style_rules[ '#thumbnails_id span' ] = 'width:100px; height:100px; display: inline-block; overflow: hidden;';
style_rules[ '#thumbnails_id img' ] = 'width:100%;';

style_rules[ 'body' ] = 'line-height: 1.425 !important'; 		// Lower values let thumbnails overlap image controls. 

// Spinners to indicate loading submissions / loading images:
style_rules[ '#submissions_spinner_id' ] = 'position: absolute; left: 0px; top: 0px; z-index: 10; border: 8px solid #3498db; border-top: 8px solid #111111; border-bottom: 8px solid #111111; border-radius: 50%; width: 48px; height: 48px; box-sizing: unset !important; transition: transform 10000000s linear' 		// The blue one. 
style_rules [ '#images_spinner_id' ] = 'position: absolute; left: 8px; top: 8px; z-index: 9; border: 24px solid #db9834; border-top: 24px solid #aaaaaa; border-bottom: 24px solid #AAAAAA; border-radius: 50%; width: 0px; height: 0px; box-sizing: unset !important; transition: transform 30000000s linear' 		// The orange one. 
style_rules[ '.spinning a' ] = 'transform: rotate(3600000000deg);'; 

// Push all of that into a <style> block:
html += '<style> ';
for( let selector in style_rules ) { html += selector + ' { ' + style_rules[ selector ] + ' } '; } 
html += '#style_check_id{ display: none; } '; 		// If this works, don't enforce_style. 
html += '</style>';



	// ----- //			Page elements



// Put everything in one DIV so I can change the background color. Yes, this is completely necessary. 
html += '<div id="backdrop_id">'; 

// Floaty stuff:
html += '<span id="spinners_id">' 		// So enforce_style() can apply to spinners. Sort of. 
	+ '<a id="submissions_spinner_id"></a>' 		// Spinner for submissions, 'new images being found.' Blue. 
	+ '<a id="images_spinner_id"></a>' 		// Spinner for images, 'images loading in high-res.' Orange. 
	+ '<span id="image_counter_id"></span>' 
	+ '</span>'

// Structure:
html += '<br><span id="controls_id" class=""></span><br><br><br><br>' 		// Image-size controls. 
	+ '<span id="thumbnails_id"></span><br><br><br>' 
	+ '<center>'
	+ '<span id="dark_mode_id"></span><br>' 
	+ '<br><span id="centered_id"></span>' 		// Where most stuff goes.
	+ '<br><br><br>' 		// Spacing for prev / next links. 
	+ '<span id="links_id"></span>' 	// Previous Page / Next Page. 
	+ '</center>'
	+ '<div style="position: absolute;" id="style_check_id"></div>' 		// Invisible probe for enforce_style. 
	+ '<br><br><br><br><br>' 		// Runout.

html += '</div>';

// Additional HTML may be added by per-site functions. Once injected, we use the DOM. 





// ------------------------------------ General setup & Per-site functions ------------------------------------ //





	// ----- //			Global variables 



var trigger_size = [ 15, 70, 25, 10 ]; 			// Left, top, font-size, padding. Pixiv defaults. 
var items; 					// Scraped contents of page. 
var formats = [ '.png', '.jpg', '.gif', '.jpeg' ]; 		// File extensions for guessing URLs. 
var video_formats = [ '.webm', '.mp4' ]; 
var next_page, previous_page; 		// URLS to the actual previous / next page (where applicable). 
var page_number; 		// Surprise, this is global again. 
var undo_list = new Array; 				// For holding and restoring elements. (See undoable_replace.) First in, last out. 

var gather_items; 		// Per-site function to scrape current page contents. 
var button_delay_function = ( () => gather_items()[0] ); 	// Don't show Swallow Gallery button while this returns false. (Alternative to @includes.) 
var image_from_dom; 	// Per-site function to scrape fetched HTML (if relevant). 



	// ----- //			Helper functions 



// Things specific to this script and its global variables:

// Replace .jpg, .png, etc., because full-size image formats don't always match their thumbnail format. 
function scrub_extensions( url ) { 
	formats.forEach( ext => {
		url = ( '' + url ).replace( ext, '%format' );
	} )
	return url; 
} 

// Fake CSS with inline style, if in-page CSS is prevented by CSP. I hate the modern web. 
function enforce_style( parent ) { 
	if( document.getElementById( 'style_check_id' ).clientWidth == 0 ) { return; } 		// Inline <style> block, not in style_rules. 
	for( let selector in style_rules ) { 		// Global
		Array.from( ( parent || document ).querySelectorAll( selector ) ) 		// Whole document by default. 
			.forEach( element => { 
				element.style = element.style.cssText + style_rules[ selector ]; 
			} )
	} 
} 

// Fetch HTML, interpret as DOM, pass DOM to standard get-image(s) function, flag image(s) as ready to display. 
function standard_fetch( item_object, span ) { 
	fetch( item_object.page )
		.then( response => { 		// On fetch error, retry: set "ready" in 10-20 seconds. 
			if( ! response.ok ) { setTimeout( () => span.classList.add( "ready" ), 1000 * ( 10 + Math.random() * 10 ) ); } 
			return response.text(); 
		} )
		.then( text => { 
			let doc = document.createElement( 'html' );
			doc.innerHTML = text; 
			item_object.image = image_from_dom( doc, item_object ); 
			span.classList.add( "ready" ); 
		} )
} 

// Grab element, swap it out, and keep both, so they can be swapped back. 
// Format is an array of objects, with properties .original and .replacement, both Elements. 
function undoable_replace( current, replacement ) { 
	if( ! current.classList.contains( 'basket' ) ) 		// When removing images, mark submission as modified.
		{ current.closest( '.submission' ).querySelector( '.basket' ).classList.remove( 'unmodified' ) } 
	current.replaceWith( replacement ); 
	undo_list.push( new Object( { original: current, replacement: replacement } ) ); 
	return undo_list[ undo_list.length - 1 ]; 
}

// DRY for submissions. 
// "page" is some <a>, "title" is in its URL, "thumb" is the <img> inside the <a>, and "image" is some scrub(thumb).replace() job.
function typical_item( page, thumb_to_image ) { 
	let title = [ 'id=', 'post/', 'posts/', 'images/', 'view/', 'show/', '/s/' ] 		// Safely extract number after one of these indicators. 
		.map( v => ( ( '' + page ).match( v + '([0-9]+)' ) || [''] ).pop() ).join( '' ) || "Link"; 	// ... or give up and be generic. 
	let thumb = ( page.querySelector( 'img' ) || { src: '' } ).src;  		// img.src, but safely. 
	return new Object( { page: page, title: title, thumb: thumb, 
		image: thumb_to_image && thumb_to_image( scrub_extensions( thumb ) )  		// No item.image for fetched sites.
	} )

}

// DRY for page links via querySelector. 
function previous_and_next( previous, next ) { 
	[ previous_page, next_page ] = [ previous, next ].map( q => document.querySelector( q ) ) 
} 

// Okay fine I'll DRY the ?p=n links. (Just e-Hentai and Pixiv at the moment.) 
function links_from_page_number( indicator, ordinal ) { 
	page_number = ( parse_search( window.location.search )[ indicator ] || ordinal ) | 0; 		// Parse search again - parseInt or default. 
	let basis = window.location.origin + window.location.pathname + "?" + indicator + "=" 
	if( page_number > ordinal ) { previous_page = basis + (page_number - 1); }
	next_page = basis + (page_number + 1); 
}



// Things that really ought to be trivial and standard, but are a pain in the ass:

// Turn window.location.search into a sensible associative array. 
function parse_search( search_string ) { 
	let strings = search_string.split( /[\?\&]/ )
		.filter( v => v ) 		// Remove empty strings
	let associative = new Object;
	strings.forEach( v => associative[ v.split('=')[0] ] = v.split('=')[1] ); 
	return associative; 
}

// Sensible "are we on this site or not?!" function. 
function domain( ending ) { 
	let want = ending.split( '.' ).reverse(); 		// Reverse order, from TLD to domain to subdomain(s). 
	let have = document.domain.split( '.' ).reverse();
	for( let n = 0; n < want.length; n++ ) {
		if( want[n] != have[n] ) { return false; } 
	} 	// Implicit else:
	return true; 
} 

// createElement / set attributes / appendChild pattern. 
HTMLElement.prototype.addElement = function( tag, attribute_object ) { 
	let element = document.createElement( tag ); 
	for( let attribute in attribute_object ) { element[ attribute ] = attribute_object[ attribute ]; } 
	this.append( element ); 
	return element; 
}

// Return the next / previous instance of a selector, relative to this element. 
HTMLElement.prototype.nextQuery = function ( selector, direction ) { 
	let reference = this.closest( selector ); 		// Ancestor or self, whatever. 
	let list = Array.from( document.querySelectorAll( selector ) ); 
	let index = list.findIndex( e => e == reference ); 
	return list[ index + ( direction || 1 ) ]; 		// Default direction is forward. 
}
HTMLElement.prototype.previousQuery = function ( selector ) { 
	return this.nextQuery( selector, -1 ); 
}

// "A link containing this text" should not be so goddamn complicated. 
Element.prototype.querySelectorIncludes = function ( query, text ) { 
	return Array.from( this.querySelectorAll( query) )
		.find( v => v.innerText.includes( text ) ); 
}



	// ----- //			Per-site setup and gather functions 



var args = parse_search( window.location.search ); 		// If it happens anywhere it might as well happen here. 

// Typical site format is as follows.
// If we're on this domain:
	// Position the "Swallow Gallery" button via trigger_size. 
	// Grab previous_page / next_page URLs. 
	// Define gather_items to return an array of objects. 
		// "Item" objects include page URL, link title, and thumb URL.
			// Item.image is either a single URL or an array of URLs. 
			// Leave item.image blank if using fetch(). See below. 
	// Change button_delay_function if gather_items should only be called once. 
		// (E.g. if gather_items changes the page or runs slowly.) 
	// When using fetch() to get item.page, define image_from_dom to get item.image from that page. 

if( domain( 'pixiv.net' ) ) {
	formats = [ ".png", ".jpg", ".gif" ]; 	// I have never seen a JPEG on Pixiv. I have 100,000 _p0 JPGs, and they're all ".jpg". 
	new_image_rate = fetch_rate; 			// Slow down, since we now load all images directly. 

	// Ever-useful test profile: https://www.pixiv.net/en/users/53625793 
	// SFW profile for testing when logged-out: https://www.pixiv.net/en/users/44241794 
	// Series: https://www.pixiv.net/user/2258616/series/38203, https://www.pixiv.net/user/55117629/series/87453?p=2 
	// Did a lot of remove-empty-container and undo testing here: https://www.pixiv.net/en/users/45227836/artworks
	button_delay_function = () => 
		document.querySelector( 'img[src*="360x"]' ) || 		// /series
		document.querySelector( 'img[src*="250x"]' ) || 		// /users
		document.querySelector( 'a *[style*="c/240"]' ); 		// bookmark_new_illust

	gather_items = function() { 
		links_from_page_number( 'p', 1 ); 

		// https://i.pximg.net/c/360x360_70/img-master/img/2020/06/20/11/14/47/82439841_p0_square1200.jpg
		let thumbs = Array.from( document.querySelectorAll( 'img[src*="c/360"]' ) ) 		// /series
			// https://i.pximg.net/c/250x250_80_a2/img-master/img/2020/05/15/04/50/43/81571620_p0_square1200.jpg 
			// https://i.pximg.net/c/250x250_80_a2/custom-thumb/img/2021/03/13/21/24/07/88416537_p0_custom1200.jpg
			.concat( ... document.querySelectorAll( 'section img[src*="c/250"]' ) ) 		// en/users/12345 - also "related works" on artworks pages? 
			// url("https://i.pximg.net/c/240x240/img-master/img/2021/02/17/04/05/41/87838098_p0_master1200.jpg") 
			.concat( ... document.querySelectorAll( 'div[style*="c/240"]' ) ) 		// bookmark_new_illust 

		return thumbs.map( t => { 
			let thumb = t.src || t.style.backgroundImage.match( /\"(.*)\"/ )[1]; 
			let image_part = thumb.match( /img\/(.*?)_/ )[1]; 		// 2021/03/07/07/11/52/88269944;
			let submission = image_part.split( '/' ).pop(); 		// 88269944
			let count = t.closest( 'a' ).querySelector( 'a .gWvsci span:not([class]), ._3b8AXEx span:not([class])' ); 
			return new Object( { 
				page: 		'https://www.pixiv.net/artworks/' + submission,
				title: 		submission,
				thumb: 	thumb,
				image: 	new Array( count && count.innerText | 0 ).fill( 0 ) 		// new Array( null ) has one element, which we replace. 
					.map( (v,i,a) => 'https://i.pximg.net/img-original/img/' + image_part + '_p' + i + '%format' ) 
			} ) 
		} ) 
	}
}

if( domain( 'gelbooru.com' ) || domain( 'safebooru.org' ) ) {
	// https://gelbooru.com/index.php?page=post&s=list&tags=shuujin_academy_uniform+chair+1girl+pink_background 
	trigger_size = [ 15, 50, 16, 5 ]; 		// Left, top, font-size, padding. 

	let current = document.querySelector( '#paginator b, .pagination b, .paginator b' ) 
	previous_page = current.previousElementSibling; 
	next_page = current.nextElementSibling; 

	// https://gelbooru.com/index.php?page=post&s=view&id=4179699&tags=pink_background
		// https://img3.gelbooru.com/thumbnails/f5/c7/thumbnail_f5c7826072943fd72076ba9121b473f0.jpg
		// https://img3.gelbooru.com/images/f5/c7/f5c7826072943fd72076ba9121b473f0.jpg
		// https://safebooru.org/thumbnails/3259/thumbnail_91588bf615a2d239d5b09c7c959236bc17b58ca6.jpg?3389404
		// https://safebooru.org//images/3259/91588bf615a2d239d5b09c7c959236bc17b58ca6.jpg?3389404 
	gather_items = () => Array.from( document.querySelectorAll( 'a[href*="s=view"]' ) )
		.map( v => typical_item( v, t => t.replace( '/thumbnails', '/images' ).replace( /_*thumbnail_*/, '' ) ) ); 
}

if( domain( 'e621.net' ) || domain( 'e926.net' ) ) {
	// https://e621.net/posts?tags=somik+mirror
	trigger_size = [ 15, 50, 16, 5 ]; 		// Left, top, font-size, padding. 
	if( window.location.href.indexOf( '/pools/' ) > 0 ) { top_to_bottom = true; } 
	previous_and_next( '#paginator-prev', '#paginator-next' ); 

	// https://e621.net/posts/1333873?q=somik+mirror
		// https://static1.e621.net/data/preview/37/75/3775cd8664c688f98a41780f6796ce86.jpg
		// https://static1.e621.net/data/37/75/3775cd8664c688f98a41780f6796ce86.png
	gather_items = () => Array.from( document.querySelectorAll( 'article.post-preview:not(.blacklisted-active)' ) )
		.map( v => typical_item( v, t => t.replace( '/preview', '' ) ) ); 
}

if( domain( 'hentai-foundry.com' ) ) {
	// http://www.hentai-foundry.com/users/FaveUsersRecentPictures?username=AmaZima
	// http://www.hentai-foundry.com/user/InCase/faves/pictures
	// http://www.hentai-foundry.com/pictures/user/AmaZima/scraps - single image, no pages 
	// Haha, http://www.hentai-foundry.com/pictures/user/Underrock/883176/MTWD-Ch.-II-Pg.-16#dnr#&dnr shows both PNG & JPG. 
	trigger_size = [ 15, 20, 25, 10 ]; 
	previous_and_next( 'li.previous a', 'li.next a' ); 

	gather_items = () => Array.from( document.querySelectorAll( 'a.thumbLink' ) ) 
		.map( v => {
			// http://www.hentai-foundry.com/pictures/user/AmaZima/589016/Tired-but-happy-Lottie
			let p = v.href.split( '/' ); 
			let username = p[5]; 	// AmaZima
			let title = p[6]; 			// 589016
			return new Object( { 
				page: 		v,
				title: 		title, 
				// url("//thumbs.hentai-foundry.com/thumb.php?pid=589016&size=350")
				thumb: 	v.querySelector( 'span[style]' ).style.backgroundImage
					.match( /\"(.*)\"/ )[1],  
				// http://pictures.hentai-foundry.com/a/AmaZima/589016/AmaZima-589016-Tired_but_happy_Lottie.png
				// http://pictures.hentai-foundry.com/t/Tixnen/869342/Tixnen-869342-Vasilina.jpg - extension matters, filename doesn't. 
				image: 	[ 	( window.location.protocol + "//pictures.hentai-foundry.com" ), 
									username[0].toLowerCase(), username, title, 
									( username + '-' + title + '-' + p[7].replace( /-/g, '_' ) + '%format' )
								].join( '/' )
			} )
		} ) 
} 

// Should probably have some generic test for Mastodon, even if I use @includes. Because I use @includes. 
// Maybe put that last? Like this approach is only valid if gather_items is undefined. 
if( domain( 'baraag.net' ) || 
domain( 'equestria.social' ) || 
domain( 'botsin.space' ) || 
domain( 'pawoo.net' ) || 
domain( 'mastodon.art' ) || 
domain( 'mastodon.social' ) ) { 
	// https://baraag.net/@Applalt/media
	// https://baraag.net/@Applalt/media?max_id=105165058865018699 // Yeesh. 
	// One element on first page: next. One element past first page: previous. Otherwise: previous, next. 
	[ next_page, previous_page ] = document.querySelectorAll( 'a[class*="load-more"]' ); 		// Usually reversed: 
	[ 'max_id', 'page' ].forEach( v => { if( args[ v ] ) { [ previous_page, next_page ] = [ next_page, previous_page ]; } } ); 

	gather_items = () => Array.from( document.querySelectorAll( '.h-entry, .h-cite' ) ) 
		.filter( v => v.querySelector( 'div[data-props*="media"]' ) ) 		// Text-only posts fuck us up. 
		.map( v => {
			let item = new Object; 
			item.page = v.querySelector( 'a[class*="time"][href]' ).href; 
			item.title = item.page.split( '/' ).pop(); 

			// dataset.props example: Object { height: 343, sensitive: true, autoplay: true, media: Array[3] }
			// .media example: Array [ Object, Object, Object ]
			// .media[0]: Object { id: "105651575500861937", type: "image", 
				// url: "https://baraag.net/system/media_att…", preview_url: "https://baraag.net/system/media_att…", 
				// remote_url: null, preview_remote_url: null, text_url: "https://baraag.net/media/C62aWhAqWt…", 
				// meta: Object, description: null, blurhash: "UFCGJfX99@RP^%t7OoV@4ms:kpn+x[V@Rja#" }
			// ... but none of those URLs are the post URL, so keep original item.page code. 
			let data = JSON.parse( v
					.querySelector( 'div[data-props]' )
					.dataset.props )
				.media; 		// Array of objects

			if( data ) { 		// Clunky workaround for some video submissions. Only some. 
				item.thumb = data.map( v => v.preview_url )[0]; 	// One thumbnail per submission. 
				item.image = data.map( v => v.url ); 
			} 
			return item; 
		} ) 
} 

if( domain( 'rule34.paheal.net' ) ) {
	// http://rule34.paheal.net/post/list/Marco_Diaz%20Polyle/1 
	[ previous_page, next_page ] = [ 'Prev', 'Next' ].map( q => document.body.querySelectorIncludes( 'a', q )	);

	gather_items = () => Array.from( document.querySelectorAll( '.shm-thumb' ) ) 
		.map( v => new Object( { 
			page: 		v.querySelector( '.shm-thumb-link' ),
			title: 		v.querySelector( 'img' ).id.replace( 'thumb_', '' ), 
			// https://lotus.paheal.net/_thumbs/12b056b3d74a9a7a8680ccb656cf8622/thumb.jpg
			// https://lotus.paheal.net/_images/12b056b3d74a9a7a8680ccb656cf8622/3736567%20-%20Amber_O%27Malley%20Dumbing_of_Age%20Joyce_Brown%20jcdr.png
			// https://lotus.paheal.net/_images/12b056b3d74a9a7a8680ccb656cf8622/fashion-bonanza.horse
			// Note: Paheal accepts any damn thing. Genuinely anything. Just replace( _thumbs, _images ) and it would roll with it. 
			// ... but direct image links are right there on the page. 
			thumb: 	v.querySelector( 'img' ).src,
			image: 	v.querySelector( 'a[href*="/_images"]' ).href
		} ) )
}

if( domain( 'rule34.xxx' ) ) {
	// https://rule34.xxx/index.php?page=post&s=list&tags=davepetasprite%5e2+
	trigger_size = [ 150, 5, 16, 5 ]; 
	previous_and_next( 'a[alt="back"', 'a[alt="next"' ); 

	// https://rule34.xxx/index.php?page=post&s=view&id=4584602 
		// https://miami.rule34.xxx/thumbnails/3500/thumbnail_62b3adfaa4d100c4a6fc7d419f61dd49.jpg?3944961
		// https://us.rule34.xxx//images/3500/62b3adfaa4d100c4a6fc7d419f61dd49.png?3944961
	gather_items = () => Array.from( document.querySelectorAll( '.thumb a' ) )
		.map( v => typical_item( v,   
			t => t.replace( /\/\/.*\.rule/, '//us.rule' ) 		// E.g. //miami.rule34.xxx -> //us.rule34.xxx
				.replace( '/thumbnails', '/images' )
				.replace( 'thumbnail_', '' ) 
		) ) 
}

if( domain( 'derpibooru.org' ) ) {
	// Note: this won't show blacklisted images, including "suggestive" images blacklisted for anonymous users. 
		// This behavior does not match Image Glutton but does match other sites in Gallery Swallower. 
		// Though I should probably .filter for known not-the-image thumbnails so nothing shows up. Hm. 
	trigger_size = [ 15, 55, 16, 5 ]; 		// Left, top, font-size, padding. 
	previous_and_next( 'a.js-prev', 'a.js-next' ); 

	// https://derpibooru.org/images/2301306?q=artist%3Ahexado
		// https://derpicdn.net/img/2021/3/28/2581076/thumb.png
		// https://derpicdn.net/img/view/2021/3/28/2581078.png 
	gather_items = () => Array.from( document.querySelectorAll( 'a[title*="Tagged"]' ) ) 
		.map( v => typical_item( v, t => t.replace( '/img/', '/img/view/' ).replace( '/thumb', '' ) ) ); 
} 

if( domain( 'inkbunny.net' ) ) {
	// https://inkbunny.net/submissionsviewall.php?rid=e16e4b981e&mode=search&page=1&orderby=create_datetime&artist=Iztli
	// Custom thumbnail test - https://inkbunny.net/gallery/atryl/1/734675c046 - they just don't add _noncustom. It's fine. 
	// This seems to mess with your preview size settings. 
	trigger_size = [ 15, 265, 16, 5 ]; 
	previous_and_next( 'a[title="previous page"', 'a[title="next page"' ); 
	html += '<style> span { color: #ddd } </style>'; 		// Default colors are grey-on-grey. 
	if( args.mode == "pool" ) { top_to_bottom = true; } 

	gather_items = () => Array.from( document.querySelectorAll( '.widget_imageFromSubmission a' ) ) 
		.map( v => typical_item( v ) ); 

	// An array of either one thumbnail or all thumbnails (if this submission has multiple files), mapped to convert to full-size. 
	image_from_dom = ( doc, item ) => ( ! doc.querySelector( '#files_area' ) ? [ item.thumb ] :
		Array.from( doc.querySelectorAll( '.widget_imageFromSubmission img[title*="page"]' ) ).map( img => img.src ) )
			.map( thumb => scrub_extensions( thumb )
				.replace(  /thumbnails\/[a-z]*/, 'files/full' ) 		// thumbnails/medium, thumbnails/large, whatever. 
				.replace(  '_noncustom%', '%' ) 		// % for scrub_extensions %format. Effectively $. 
			)
}

if( domain( 'furaffinity.net' ) ) {
	// https://www.furaffinity.net/gallery/mab/folder/808215/Divaea 
	trigger_size = [ 15, 155, 16, 5 ]; 

	// https://www.furaffinity.net/scraps/mab/2/
	// https://www.furaffinity.net/gallery/mab/folder/43380/Wildcard/2/
	// https://www.furaffinity.net/msg/submissions/new~40875552@48/ 
	// Completely goofy previous / next links. Sometimes links with no distinction besides text. Sometimes forms. Forms! Like it's 1998!
	[ previous_page, next_page ] = [ 'Prev', 'Next' ].map( q => document.body.querySelectorIncludes( 'form', q ) ).map( f => f && f.action ); 
	if( window.location.href.includes( 'msg/submissions' ) ) { previous_and_next( 'a.more-half.prev', 'a.more, a.more-half:not(.prev)' ); }

	button_delay_function = () => document.querySelector( 'figure.t-image' ); 

	gather_items = function() { 
		let items = Array.from( document.querySelectorAll( 'figure.t-image u a' ) ) 
			.map( v => typical_item( v ) ); 
		// Minor witchcraft - some part of FA hits an infinite loop if you replace the page and then resize anything. 
		// I don't understand what exactly it's doing. But I know that removing some things first can break it: 
		Array.from( document.querySelectorAll( 'a' ) ).forEach( v => v.remove() ); 
		return items; 
	}

	image_from_dom = ( doc ) => doc.querySelector( '.download a' ).href; 
} 

if( domain( 'danbooru.donmai.us' ) ) {
	// https://danbooru.donmai.us/posts?tags=marble_macintosh
	// Forced to fetch, because "click for original size" images use a different CDN. 
	// I could try making an educated guess based on data-width and data-large-width in the <article> properties. Meh. 
	trigger_size = [ 15, 100, 16, 5 ]; 		// Left, top, font-size, padding. 
	previous_and_next( 'a.paginator-prev', 'a.paginator-next' ); 

	gather_items = () => Array.from( document.querySelectorAll( 'article.post-preview a' ) )
		.map( v => typical_item( v ) ); 

	image_from_dom = ( doc ) => doc.querySelector( 'a[download]' ).href.split('?')[0]; 	// Trim ?download stuff. 
} 

if( domain( 'booru.org' ) ) {
	// https://svtfoe.booru.org/index.php?page=post&s=list&tags=socks&pid=20
	trigger_size = [ 15, 55, 16, 5 ]; 		// Left, top, font-size, padding. 
	previous_and_next( 'a[alt="back"]', 'a[alt="next"]' );

	// https://svtfoe.booru.org/index.php?page=post&s=view&id=29292
	 	// https://thumbs.booru.org/svtfoe/thumbnails//28/thumbnail_187209e3ca22a28fd1ce75a7fd4f54aee3cf1e62.jpg
		// https://img.booru.org/svtfoe//images/28/187209e3ca22a28fd1ce75a7fd4f54aee3cf1e62.jpg
	gather_items = () => Array.from( document.querySelectorAll( 'span.thumb a:not([style])' ) ) 		// Specifying not:(display:none) is weirdly difficult. 
		.map( v => typical_item( v, t => t.replace( 'thumbs.', 'img.' ).replace( '/thumbnails', '/images' ).replace( 'thumbnail_', '' ) ) ); 
} 

if( domain( 'jabarchives.com' ) ) { 
	// https://www.jabarchives.com/main/gallery/misterd/105
	trigger_size = [ 90, 55, 16, 5 ]; 		// Left, top, font-size, padding.
	// Two identical .pagination bars. So: "previous" is before the current-page link in the first bar, and "next" is after the one in the second bar. 
	let this_link = document.querySelectorAll( 'li.page-item.active a' ) 
	if( this_link[0] ) { previous_page = this_link[0].previousQuery( 'li.page-item a' ) } 
	if( this_link[1] ) { next_page = this_link[1].nextQuery( 'li.page-item a' ); }

	// https://jabarchives.com/main/post/10577
		// https://jabarchives.com/main/media/posts/2017/09/10/1L511532132607200182023271_thumb.png
		// https://jabarchives.com/main/media/posts/2017/09/10/1L511532132607200182023271_large.png
	gather_items = () => Array.from( document.querySelectorAll( 'li.gallrpli a.image' ) )
		.sort( (x,y) => x.href.split('/').pop() | 0 < y.href.split('/').pop() | 0 ) 		// Forced chronological order. 
		.map( v => typical_item( v, t => t.replace( '_thumb', '_large' ) ) ); 
} 

if( domain( 'aryion.com' ) ) { 
	trigger_size = [ 5, 110, 16, 5 ]; 		// Left, top, font-size, padding. 
	[ previous_page, next_page ] = [ '<<', '>>' ].map( q => document.body.querySelectorIncludes( '.pagenav a', q )	);

	// https://aryion.com/g4/view/486096
	// https://aryion.com/g4/gallery/Mortaven - with folders. 
	gather_items = () => Array.from( document.querySelectorAll( '.type-Images' ) ) 		// Avoid folders. 
		.map( v => v.closest( 'li' ) ) 		// Link / image are separate from folder indicator. 
		.map( v => typical_item( v.querySelector( 'a' ) ) ); 

	image_from_dom = ( doc ) => doc.querySelector( 'meta[property*="secure_url"]' ).content; 		// Possibly not the largest size?
} 

if( domain( 'e-hentai.org' ) ) { 
	trigger_size = [ 5, 40, 16, 5 ]; 		// Left, top, font-size, padding. 
	top_to_bottom = true; 		// They're all in page order. 
	links_from_page_number( "p", 0 ); 

	gather_items = () => Array.from( document.querySelectorAll( '.gdtm a' ) )
		.map( (v,i,a) => new Object( {  		// This site has no discrete thumbnails. 
			page: 		v, 
			title: 		'Page ' + ( page_number * 40 + i + 1 ),
		} ) )

	image_from_dom = ( doc ) => doc.querySelector( '#img' ).src; 
} 

if( domain( 'lolibooru.moe' ) ) { 
	trigger_size = [ 30, 150, 16, 5 ]; 		// Left, top, font-size, padding. 
	previous_and_next( 'a.previousPage', 'a.nextPage' ); 

	// https://lolibooru.moe/data/preview/a5ea50c715ce6e4d100cd648894495b1.jpg
	// https://lolibooru.moe/image/a5ea50c715ce6e4d100cd648894495b1/lolibooru%20240335%20age_difference (etc) .jpg - Anything works. 
	// https://lolibooru.moe/image/a5ea50c715ce6e4d100cd648894495b1.jpg works, so just do that. 
	gather_items = () => Array.from( document.querySelectorAll( 'li a.thumb' ) )
		.map( v => typical_item( v, t => t.replace( 'data/preview', 'image' ) ) )
} 

if( domain( 'yande.re' ) ) { 	// Aggravatingly close to Lolibooru, but incompatible. 
	trigger_size = [ 30, 105, 16, 5 ]; 		// Left, top, font-size, padding. 
	previous_and_next( 'a.previous_page', 'a.next_page' ); 

	// Some images are /jpeg/ and some images are /image/ and there's no way to tell. Dammit. So we fetch. 
	gather_items = () => Array.from( document.querySelectorAll( 'li a.thumb' ) )
		.map( v => typical_item( v ) ); 

	image_from_dom = ( doc ) => doc.querySelector( '.highres-show' ).href; 
} 

if( domain( 'thehentaiworld.com' ) ) { 
	trigger_size = [ 25, 125, 16, 5 ]; 		// Left, top, font-size, padding. 
	previous_and_next( 'a.prev', 'a.next' ); 

	gather_items = () => Array.from( document.querySelectorAll( 'div.thumb img' ) )
		.map( v => typical_item( v.closest( 'a' ) ) ) 

	image_from_dom = ( doc ) => doc.querySelector( '#miniThumbContainer' ) ? 		// If multi-image,
		Array.from( doc.querySelectorAll( '#miniThumbContainer img' ) ) 					// Modify thumbnails. 
			.map( v => scrub_extensions( v.src ).replace( '-220x147', '' ) )
		: doc.querySelector( '#info li a' ).href;  																// Else return main image. 
} 


	// ----- //			Controls to invoke script



GM_registerMenuCommand( "Swallow entire gallery", show_images ); 		// TamperMonkey still uses this! Good. 

// Put button on page, since there's no menu in "modern" Userscript plugins.
var add_button = setInterval( function() { 
		if( button_delay_function() ) { 		// Periodically check if this page has what we're looking for. 
			clearInterval( add_button ); 
			document.body.addElement( 'button', { innerText: "Swallow gallery", 
				onclick: function() { this.innerText='Swallowing...'; show_images(); }, 		// Oh. I didn't know I could just call that from here. 
				style: "position: absolute; color:#194d19 !important; background-color:#dbd7d8; border-radius: 20px; text-align: center; z-index: 10; "
					+ "display: inline-block; border:1px solid #19ab19; cursor:pointer; line-height: 20px; font-family:Arial; text-decoration:none; "
					+ "left: " + trigger_size[0] + "px; top: " + trigger_size[1] + "px; font-size:" + trigger_size[2] + "px; padding: " + trigger_size[3] + "px " + trigger_size[3] + "px;"
			} )
		} 
	}, 100 ); 		// Passive - no interaction concerns. 

// End of main execution. 





// ------------------------------------ Gallery Swallower ------------------------------------ //





function show_images() { 



	// ----- //			Replace page, set up furniture 



	// Grab links and/or thumbnails using per-site code:
	items = gather_items(); 		// Array of objects, listing page link, thumbnail, presumed fullsize image, etc. 
	if( ! top_to_bottom ) { items.reverse(); } 

//	console.log( items ); 		// Debug. Be honest, this is staying here. 
//	items = []; 		// Debug aid for checking previous page / next page. 

	// Erase existing page, use ours instead.
	document.body.innerHTML = html; 

	// Fetch pages less often than we'd add inline images, to avoid 503 errors. 
	if( image_from_dom ) { new_image_rate = fetch_rate; } 

	// Controls - e.g. image size and order. 
	let controls_id = document.getElementById( 'controls_id' ); 
	controls_id.appendChild( document.createTextNode( "Size: " ) ); 
	let symbols = { short: "▣", fit_width: "↔", fit_height: "↕", fit_window: "✢", full: "■" }; 
	for( let label in symbols ) { 		// DRY for five nearly-identical buttons. 
		controls_id.addElement( 'button', { innerText: symbols[ label ], className: "control_button eza_button", id: label, 
			title: label[0].toUpperCase() + label.substr( 1 ).replace( '_', ' ' ), 
			onclick: function () { 
				document.getElementById( 'centered_id' ).className = controls_id.className = this.id;
				enforce_style();
			}
		} );
		controls_id.appendChild( document.createTextNode( " " ) ); 		// Asinine way to force spacing. ::after wouldn't obey enforce_style. 
	} 
	document.getElementById( default_size ).click(); 		// DRY for highlighting the active size. 

	controls_id.appendChild( document.createTextNode( " Order: " ) ); 
	controls_id.addElement( 'button', { className: "control_button eza_button", title: "Reverse order", innerText: '⇅',
		onclick: function() { 
			document.querySelectorAll( '.reversible' )
				.forEach( node => node.parentElement.insertBefore( node, node.parentElement.firstChild ) )
		} } ); 

	// Navigation links, at the bottom. "Previous" or "Previous - Next" or "Next".
	let links_id = document.getElementById( 'links_id' ); 
	links_id.innerHTML += previous_page ? "<a href='" + previous_page + "'>Previous page</a>" : ""; 
	links_id.innerHTML += previous_page && next_page ? " - " : ""; 
	links_id.innerHTML += next_page ? "<a href='" + next_page + "'>Next page</a>" : ""; 

	// Global undo button, pinned to the corner.
	let global_undo = document.body.addElement( 'button', { title: 'Undo',  innerText: '⟲', 	
		className: 'reloader undo eza_button', id: 'global_undo_id', 
		onclick: function() { 
			if( ! undo_list[0] ) { return; } 	
			let element = undo_list.pop(); 
			element.replacement.replaceWith( element.original ); 
			element.replacement.remove(); 		// Fingers crossed this frees the memory. Not super important. 
			element.original.scrollIntoView(); 
		} } ); 

	// Dark mode button.
	document.getElementById( 'dark_mode_id' ).addElement( 'button', { id: 'dark_mode_button', innerText: '✺',
		className: 'control_button eza_button', title: 'Dark mode',
		onclick: function() { 
			document.getElementById( 'backdrop_id' ).classList.toggle( 'dark' );
			enforce_style(); 
		} 
	} )

	// Start spinners. @keyframes won't work on e.g. Baraag. Transition requires an initial state, hence this delayed start. 
	document.querySelector( '#spinners_id' ).className = 'spinning'; 


	// ----- //			Per-submission links and controls 



	// Give each item its own set of spans, with basic onClick controls to remove images or reload a submission.
	// Very few of these need to be variables now, but 'let purpose =' adds clarity. It's no less efficient than before. 
	for( let item_key = 0; item_key < items.length; item_key++ ) { 
		let item = items[ item_key ]; 

		let container = document.getElementById( 'centered_id' ).addElement( 'div',
			{ id: item_key + 'container', className: "submission reversible" } )

		// ← ⟳ 12345 ✕ →
		let nav_previous = container.addElement( 'button', { title: 'Previous submission', className: 'nav_button previous eza_button', innerText: '←', 
			onclick: function() { this.previousQuery( '.submission' ).scrollIntoView(); } } );
		let first_spacer = container.addElement( 'button', { className: 'button_spacer eza_button' } ); 
		let reloader = container.addElement( 'button', { title: 'Reload submission', className: 'reloader eza_button', innerText: '⟳', 
			onclick: function() { 
				if( image_from_dom ) { item.image = null; } 
				let pair = undoable_replace( this.closest( '.submission' ).querySelector( '.basket' ), 
					document.body.addElement( 'span', { id: item_key, className: 'ready basket unmodified' } ) ); 
				if( pair.original.classList.contains( 'unmodified' ) ) { undo_list.pop(); } 
				pair.replacement.addElement( 'span', { className: 'sub_basket' } ); 
			} } );
		let link = container.addElement( 'a', { href: item.page + '#dnr#&dnr', 
			innerText: ' ' + item.title + ' ', target: '_blank', style: 'font-size:30px' } ); 		// Only "code smell" says this should go in CSS.
		let submission_remover = container.addElement( 'button', { title: 'Remove submission', className: 'remover top eza_button', innerText: '✕', 
			onclick: function(){ 
				if( this.closest( '.submission' ).querySelector( '.image_container' ) == null ) { return; } 		// Don't remove (and store) empty submissions. 
				undoable_replace( this.closest( '.submission' ).querySelector( '.basket' ), document.createElement( 'span' ) );  
			} } );
		let second_space = container.addElement( 'button', { className: 'button_spacer eza_button' } ); 
		let nav_next = container.addElement( 'button', { title: 'Next submission', className: 'nav_button next eza_button', innerText: '→', 
			onclick: function() { this.nextQuery( '.submission' ).scrollIntoView(); } } ); 
		container.addElement( 'br' ); 

		// Image(s) and the ✕ below each group. 
		let basket = container.addElement( 'span', { id: item_key, className: 'basket unmodified' } ); 		// Hold off on ready-ing. 
		let bottom_submission_remover = container.addElement( 'button', { innerText: '✕',
			title: 'Remove the above submission', className: 'remover bottom eza_button',
			onclick: function() {
				let sub = this.closest( '.submission' ); 
				sub.querySelector( '.remover.top' ).click(); 		// Click top X for identical behavior. 
				sub.scrollIntoView(); 		// Scroll back up, since vertical content disappeared. 
			} } );

		// Thumbails at the top. Fixed-size grid box, overflow hidden. Crops tall images.
		if( item.thumb ) { 		// Optional now, mostly because fuck e-Hentai. 
			let thumb_box = document.getElementById( 'thumbnails_id' ).addElement( 'span', { className: 'reversible',
				onclick: function() { document.getElementById( item_key + 'container' ).scrollIntoView(); } } )
			let thumbnail_image = thumb_box.addElement( 'img', { src: item.thumb } ); 
		} 

		enforce_style(); 		// Applies CSS per-element, if it needs to. 

		reloader.click(); 		// DRY
	} 



	// ----- //			Keyboard controls



	if( keyboard_controls ) { 
		// I should probably still addEventListener for scrolling and highlight the current image somehow. Control opacity? Image border? 
		// The real issue is that current_image and current_submission don't have to line up. 
		document.addEventListener( "keydown", key_handler, false );
		function onscreen( element ) { return element.getBoundingClientRect().top + element.scrollHeight > 100 } 		// DRY
		function key_handler( event ) { 
			// Find "current" elements: which image & submission are at or near the top of the screen? 
			let current_image = Array.from( document.querySelectorAll( '.image_container' ) ).find( onscreen );
			let current_submission = Array.from( document.querySelectorAll( '.submission' ) ).find( onscreen );

			switch( event.key ) {
				case 'a': current_image.querySelector( '.nav_previous_image' ).click(); break; 
				case 'd': current_image.querySelector( '.nav_next_image' ).click(); break; 
				case 'q': current_image.querySelector( '.remover.floating' ).click(); break; 
				case 'e': current_image.querySelector( '.remover.nav_button' ).click(); break; 

				case 'w': current_submission.querySelector( '.previous' ).click(); break; 
				case 's': current_submission.querySelector( '.next' ).click(); break; 
				case 'r': current_submission.querySelector( '.reloader' ).click(); break; 
				case 'x': current_submission.querySelector( '.remover.bottom' ).click(); break; 

				case 'z': document.getElementById( 'global_undo_id' ).click(); break; 		// Ctrl+Z also works.

				case 'f': 
					let current_video = current_image.querySelector( 'video' );
					if( current_video && current_video.paused ) { current_video.play(); } 
					else if( current_video ) { current_video.pause(); } 
				break; 
			} 
		}

		// Alright, make it so hitting 'next' on a freshly-loaded page goes to the first image instead of the second. 
		// Can't just append two buttons of className x_container, because thing.querySelector can't return 'thing.' 
		let top_anchor = document.getElementById( 'spinners_id' ).addElement( 'span', { className: 'image_container submission' } ); 
		let first_image = top_anchor.addElement( 'button', { className: 'nav_next_image remover nav_button',
			onclick: function() { this.nextQuery( '.image_container' ).scrollIntoView(); } } ); 
		let first_submission = top_anchor.addElement( 'button', { className: 'next',
			onclick: function() { this.nextQuery( '.submission' ).scrollIntoView(); } } ); 

		// Okay fine there should be a bottom anchor too. 
		let bottom_anchor = document.getElementById( 'backdrop_id' ).addElement( 'span', { className: 'image_container submission' } );
		let final_image = bottom_anchor.addElement( 'button', { className: 'nav_previous_image',
			onclick: function() { this.previousQuery( '.image_container' ).scrollIntoView(); } } ); 
		let final_submission = bottom_anchor.addElement( 'button', { className: 'previous',
			onclick: function() { this.previousQuery( '.submission' ).scrollIntoView(); } } ); 
	}



	// ----- // 			Autoplaying videos



	document.addEventListener( "scroll", function( event ) {
		// Videos autoplay as they come onscreen, and autopause as they go offscreen. 
		// If manually paused - they will not autoplay, until manually played again. 
		// God bless RosieKawaii for making all this testing thoroughly entertaining. 
		Array.from( document.querySelectorAll( 'video' ) ).forEach( video => { 
			let bounds = video.getBoundingClientRect(); 
			if( bounds.bottom < 0 || bounds.top > document.documentElement.clientHeight ) { 
				if( ! video.paused ) { video.classList.add( 'automatically_paused' ); } 
				video.pause(); 
			} 
			else if( video.classList.contains( 'automatically_paused' ) ) { 
				video.classList.remove( 'automatically_paused' ) 
				video.play(); 
			} 
		} )
	} , false );



	// ----- //			Ongoing interaction and loading 



	// Load more images in "ready" submissions, when available. 
	var interval_object = setInterval( function() { 

		// Upper limit for simultaneous loading images? (Should treat 0 as 'no limit,' so var && length > var.) 
//		if( document.getElementsByClassName( 'loading' ).length > 10 ) { return; } 

		// Add an image to the ready basket, increment page number, conditionally ready-up for another image. 
		let ready_element = document.querySelector( '.ready' )
		if( ! ready_element ) { return; } 
		ready_element.classList.remove( 'ready' ); 

		let item = items[ ready_element.id | 0 ]; 		// This should maybe be data-id or something. 

		// If we need to fetch, we don't have an image to display yet. 
		if( ! item.image ) { return standard_fetch( item, ready_element ); } 		// Exit interval. 

		// All per-image divs go in one per-submission span, so whole-submission X prevents new images from loading.
		let outer_span = ready_element.querySelector( '.sub_basket' ); 
		let inner_div = outer_span.addElement( 'div', { style: 'position: relative;', className: 'image_container' } ); 

		let manga_page = ( outer_span.dataset.image_number || 0 ) | 0; 		// Not web-page... comic-page. (No initial value required.) 
		outer_span.dataset.image_number = 1 + manga_page; 		// Has to go after fetch stuff, or we skip the first page. 

		// Place stuff in the container. 
		let previous_image = inner_div.addElement( 'button', { title: 'Previous image', innerText: '←', 
			className: 'nav_button nav_float nav_previous_image eza_button', 
			onclick: function() { this.previousQuery( '.image_container' ).scrollIntoView(); } 
		} ); 
		let next_image = inner_div.addElement( 'button', { title: 'Next image', innerText: '→', 
			className: 'nav_button nav_float nav_next_image eza_button', 
			onclick: function() { this.nextQuery( '.image_container' ).scrollIntoView(); } 
		} ); 
		let remove_and_advance = inner_div.addElement( 'button', { title: 'Remove image / Next image', innerText: '✕→', 
			className: 'remover nav_button nav_float eza_button', 
			onclick: function() {
				let next = this.nextQuery( '.image_container' ); 
				this.closest( '.image_container' ).querySelector( '.remover.floating' ).click(); 		// DRY - click other remove button. 
				next.scrollIntoView(); 

				let toaster = document.body.addElement( 'span', { innerText: '✕ Image removed' } )
				toaster.offsetWidth ? toaster.style = 'position: fixed; left: 60px; top: 0px; opacity: 0; transition: opacity 3s;' : null; 		// Reflow forces a distinct state to transition from. 
				setTimeout( () => { toaster.remove(); }, 3000 ); 
			} 
		} ); 

		// Prepare image filename once. 
		if( typeof( item.image ) == "string" ) { item.image = [ item.image]; } 		// Believe it or not, this is cleaner.
		let image_url = item.image[ manga_page ] || ''; 		// '' to avoid null.replace() TypeErrors.  
		for( let format of ( image_url.includes( '%format' ) ? formats : [""] ) ) { 		// One image/link per plausible filetype. (Exact URLs - one filetype.) 
// 			console.log( image_url ); 		// Debug. 
			let apng = inner_div.addElement( 'a', { href: image_url.replace( '%format', format ), className: 'image_link', target: '_generic' } ); 
			apng.addElement( 'img', { src: image_url.replace( '%format', format ), className: 'loading', 
				onload: function() { this.classList.remove( "loading" ); },
				onerror: function() { 
					let container = this.closest( '.image_container' ); 		// If all images disappear, we'll remove the whole container. 
					this.closest( 'a' ).remove(); 		// Wrong URL: remove parent link (apng) and "this" image. 
					if( getComputedStyle( container ) && ! container.querySelector( 'img, video' ) ) { container.remove(); }  		// Force reflow, avoid race condition.    
				} 
			} ); 
		} 

		// Videos: one <video>, multiple sources. error goes on last source only. invisible by default. (They take up a lot of space.) 
		let video = inner_div.addElement( 'video', { controls: true, loop: true, muted: true, className: 'invisible automatically_paused' } ) 
		inner_div.insertBefore( video, inner_div.firstChild ); 		// Reorder elements so floating X makes sense. 
		for( let extension of ( image_url.includes( '%format' ) ? video_formats : [""] ) ) { 
			video.addElement( 'source', { src: image_url.replace( '%format', extension ) } ) 
		} 
		video.lastChild.addEventListener( 'error', function() { 
			let container = this.closest( '.image_container' );
			this.closest( 'video' ).remove(); 
			if( getComputedStyle( container ) && ! container.querySelector( 'img, video' ) ) { container.remove(); } 
		} ) 

		// If a video loads, show that instead of any image. (E.g. Gelbooru uses similar URLs for "posters.") 
		let video_trigger = video.addEventListener( 'canplay', function() { 
			if( this.classList.contains( 'invisible' ) ) { 		// Once. (canplay can trigger itself and loop.)
				this.currentTime = 0;  		// Fighting race conditions. 
				let video_link = this.parentElement.addElement( 'a', { innerText: 'Video link', className: 'invisible', 		// No good place for it yet, so hide it. 
					href: this.currentSrc, download: this.currentSrc.split( '/' ).pop() } ); 		// DownThemAll uses 'page title.webm.' WHY. 
			}
			this.classList.remove( 'invisible' ); 
			this.closest( '.image_container' ).querySelector( 'a.image_link' ).remove(); 
		} ) 

		let remover = inner_div.addElement( 'button', { title: 'Remove image', innerText: '✕', className: 'remover floating eza_button', 
			onclick: function() { undoable_replace( this.closest( '.image_container' ), document.createElement( 'span' ) ); } 
		} ); 
		inner_div.addElement( 'br' ); 
		inner_div.addElement( 'br' ); 

		// Array-of-images stuff: if item.image is an array, use it, up to the last item. 
		if( item.image.keys && item.image[ manga_page + 1 ] ) { ready_element.classList.add( "ready" ); } 	// Direct testing - no booleans. 

		enforce_style( outer_span ); 		// Just for this image.

	}, new_image_rate ); 



	// ----- // 		🍭 Spinners 🍭



	// Garish spinners that indicate "finding new images" and "files still loading." 
	var spinner_interval = setInterval( function() { 
		document.getElementById( 'submissions_spinner_id' ).style.opacity = document.getElementsByClassName( 'ready' ).length; 
		document.getElementById( 'images_spinner_id' ).style.opacity = document.getElementsByClassName( 'loading' ).length; 		// Once again: the orange one.

		document.getElementById( 'image_counter_id' ).innerText = document.querySelectorAll( 'div.image_container' ).length + ' images';  

		enforce_style( document.getElementById( 'spinners_id' ) ); 
	}, 1000 ); 		// Lazy interval. 

}