[PS] Homepage Enhancements

Improves your Pokemon Showdown homepage.

当前为 2024-09-03 提交的版本,查看 最新版本

// ==UserScript==
// @name        [PS] Homepage Enhancements
// @namespace   https://greasyfork.org/en/users/1357767-indigeau
// @version     0.1
// @description Improves your Pokemon Showdown homepage.
// @match       https://play.pokemonshowdown.com/*
// @exclude     https://play.pokemonshowdown.com/sprites/*
// @author      indigeau
// @license     GNU GPLv3
// @icon        https://www.google.com/s2/favicons?sz=64&domain=pokemonshowdown.com
// @grant       none
// ==/UserScript==

// The gap between .leftmenu and its surrounding elements
const GAP_MAIN = 20;
// The gap between .pm-window elements
const GAP_PM = 10;
// The gap between the edge of .pmbox and .pm-window
const GAP_PMBOX = 10;
// The height of #header
const HEIGHT_HEADER = 50;
// The height of .maintabbarbottom (height=6px + top or bottom border=1px)
const HEIGHT_STRIP = 7;
// The width of .mainmenu
const WIDTH_BATTLE = 270;
// The width of .pm-window
const WIDTH_PM = 270;

const styleSheet = (() => {
	const styleElement = document.createElement('style');
	
	document.head.appendChild(styleElement);
	
	return styleElement.sheet;
})();

// Style changes
(() => {
	for (const rule of [
		['body > #header', [['z-index', '1']]],
		['#room-.ps-room', [
			['top', `${HEIGHT_HEADER}px`],
			['height', `calc(100% - ${HEIGHT_HEADER + 2}px)`],
			['scrollbar-width', 'none'],
		]],
		['.mainmenuwrapper', [
			['display', 'flex'],
			['align-items', 'center'],
			['width', '100%'],
			['height', '100%'],
		]],
		['.leftmenu', [
			['display', 'flex'],
			['flex-wrap', 'nowrap'],
			['flex-direction', 'row-reverse'],
			['width', '100%'],
			['height', '100%'],
			['box-sizing', 'border-box'],
			['padding', '0px'],
			['justify-content', 'center'],
			['padding', `${GAP_MAIN + HEIGHT_STRIP - 1}px 0`],
			['position', 'absolute'],
			['top', '0'],
		]],
		['.activitymenu', [['display', 'contents']]],
		['.pmbox', [
			['flex-grow', '1'],
			['display', 'flex'],
			['flex-direction', 'column'],
			['height', '100%'],
			['flex-wrap', 'wrap'],
			['overflow', 'auto'],
			['background', 'rgba(0, 0, 0, .2)'],
			['border-radius', '20px'],
			['padding', `${GAP_PMBOX}px`],
			['align-content', 'flex-start'],
			['margin-right', `${GAP_MAIN}px`],
			['box-sizing', 'border-box'],
			['align-items', 'center'],
			['scroll-snap-type', 'x mandatory'],
		]],
		['.pmbox > *', [
			['width', `${WIDTH_PM}px`],
			['max-width', `calc(100% - ${GAP_PMBOX * 2}px)`],
			['margin', `${GAP_PM}px`],
			['scroll-snap-align', 'center'],
			['scroll-margin-left', '20px'],
		]],
		['.mainmenu', [
			['width', `${WIDTH_BATTLE}px`],
			['padding', '0'],
			['margin', `0 ${GAP_MAIN}px`],
			['height', '100%'],
			['display', 'flex'],
			['flex-direction', 'column'],
			['overflow-y', 'auto'],
			['scrollbar-width', 'none'],
			['overscroll-behavior', 'contain'],
			['background', 'rgba(0, 0, 0, .2)'],
			['border-radius', '20px'],
			['justify-content', 'space-evenly'],
		]],
		['.mainmenu > .menugroup', [
			['background', 'none'],
			['margin', '0'],
			['padding', '0'],
		]],
		['.mainmenu > .menugroup:not(:first-child) p', [['margin-top', '-1px']]],
		['.mainmenu > .menugroup:not(:first-child) p > button', [['box-shadow', 'inset #000d1733 0 0 200px 0px']]],
		['.dark > body .mainmenufooter', [['background', 'rgba(0, 0, 0, .3)']]],
		[':not(.dark) > body .mainmenufooter', [['background', 'url(../fx/client-topbar-bg.png) repeat-x left top scroll']]],
		['.mainmenufooter', [
			['height', `${HEIGHT_HEADER}px`],
			['bottom', `-${HEIGHT_HEADER}px`],
			['width', '100%'],
			['left', '0'],
			['display', 'flex'],
		]],
		['.mainmenufooter small', [
			['flex-grow', '1'],
			['display', 'flex'],
			['font-size', '0'],
			['border-top', '1px solid #34373b'],
		]],
		['.dark > body .mainmenufooter > small > a', [
			['color', '#fff'],
			['box-shadow', 'inset -0.5px -0.5px 1px 0.5px rgba(255, 255, 255, .5)'],
		]],
		['.dark > body .mainmenufooter > small > a:visited', [['color', '#fff']]],
		[':not(.dark) > body .mainmenufooter > small > a', [
			['color', '#222'],
			['box-shadow', 'inset -0.5px -0.5px 1px 0.5px rgba(255, 255, 255, .5)'],
		]],
		[':not(.dark) > body .mainmenufooter > small > a:visited', [['color', '#222']]],
		['.mainmenufooter > small > a', [
			['font-size', '12pt'],
			['text-align', 'center'],
			['text-decoration', 'none'],
			['border-top-right-radius', '0'],
			['border-top-left-radius', '0'],
			['margin', '0 5px'],
			['padding', '4px 12px'],
			['height', '28px'],
		]],
		['.mainmenufooter > small > a:hover', [
			['text-decoration', 'none'],
		]],
	]) {
		styleSheet.insertRule(`${rule[0]}{${rule[1].map(([property, value]) => `${property}:${value};`).join('')}}`);
	}
	
	for (const button of document.querySelectorAll('.mainmenufooter > small > a')) {
		button.classList.add('roomtab', 'button');
	}
})();

// Helper for hiding things when the screen's too narrow
class ConditionalDisplay {
	static source = document.querySelector('.mainmenuwrapper');
	static listeners = [];
	
	constructor(width) {
		this.width = width;
		
		this.isShown = this.doShow();
	}
	
	doShow() {
		return ConditionalDisplay.source.clientWidth >= this.width;
	}
	
	add(listener) {
		ConditionalDisplay.listeners.push(() => {
			if (this.doShow() === this.isShown) {
				return;
			}
			
			this.isShown = !this.isShown;
			
			listener(this.isShown);
		});
		
		listener(this.isShown);
	}
}

(new ResizeObserver(() => {
	for (const listener of ConditionalDisplay.listeners) {
		listener();
	}
})).observe(ConditionalDisplay.source);

// Footer extras
(() => {
	// Helpers
	
	const getButton = () => {
		const button = document.createElement('img');
		
		button.classList.add('icon', 'button');
		
		button.style.margin = '0 3px';
		button.style.height = '21px';
		button.style.borderRadius = '5px';
		button.style.cursor = 'pointer';
		button.style.padding = '2px';
		button.style.boxShadow = '.5px 1px 2px rgba(255, 255, 255, .45), inset .5px 1px 1px rgba(255, 255, 255, .5)';
		
		return button;
	};
	
	// Setup container
	
	const container = document.createElement('div');
	
	container.style.fontSize = '12pt';
	container.style.flexGrow = '1';
	container.style.margin = `0 ${GAP_MAIN}px`;
	container.style.alignItems = 'center';
	
	const showContainer = (doShow) => {
		container.style.display = doShow ? 'flex' : 'none';
	};
	
	(new ConditionalDisplay(850)).add(showContainer);
	
	// Setup text
	
	const text = document.createElement('span');
	
	text.style.marginLeft = '6px';
	
	text.innerText = 'Script by indigeau';
	
	// Setup pm button
	
	const psButton = getButton();
	
	psButton.src = 'https://www.google.com/s2/favicons?sz=64&domain=pokemonshowdown.com';
	
	psButton.addEventListener('click', () => {
		app.rooms[''].focusPM('indigeau');
	});
	
	// Setup feedback button
	
	const gfButton = getButton();
	
	gfButton.src = 'https://www.google.com/s2/favicons?sz=64&domain=greasyfork.org';
	
	gfButton.addEventListener('click', () => {
		open('https://greasyfork.org/en/scripts/506533-ps-homepage-enhancements/feedback');
	});
	
	// Setup seperator
	
	const tabBar = document.createElement('div');
	
	tabBar.classList.add('maintabbarbottom');
	
	tabBar.style.top = `-${HEIGHT_STRIP - 1}px`;
	
	// Add to DOM
	
	const footer = document.querySelector('.mainmenufooter');
	const parent = footer.lastElementChild;
	
	container.append(psButton, gfButton, text);
	
	parent.insertBefore(container, parent.firstChild);
	
	footer.append(tabBar);
})();

// Sidebar
(() => {
	// CSS for :hover style because it's easier than using listeners
	
	const IMAGE_BUTTON_CLASS = 'home-style-team-image';
	
	for (let rule of [
		[`.${IMAGE_BUTTON_CLASS}:hover + img`, [
			['scale', '1.2'],
			['filter', 'drop-shadow(black 3px 4px 2px) drop-shadow(black 0 0 10px)'],
			['z-index', '1'],
		]],
	]) {
		styleSheet.insertRule(`${rule[0]}{${rule[1].map(([property, value]) => `${property}:${value};`).join('')}}`);
	}
	
	// Helpers
	
	const swapImages = (toShow, toHide) => {
		for (const image of toHide) {
			image.style.display = 'none';
		}
		
		for (const image of toShow) {
			image.style.removeProperty('display');
		}
	};
	
	const update = (backupImages, teamImages, teamButtons, doListen = true) => {
		const button = document.querySelector('.select.teamselect');
		
		if (doListen) {
			(new MutationObserver(() => {
				update(backupImages, teamImages, teamButtons, false);
			})).observe(button.parentElement, {
				characterData: true,
				childList: true,
				subtree: true,
			});
		}
		
		const teamIndex = Number.parseInt(button.value);
		
		if (!Number.isInteger(teamIndex)) {
			swapImages(backupImages, teamImages);
			
			return;
		}
		
		const teamName = button.firstChild.innerText;
		const team = Storage.teams.find(({name}) => name === teamName);
		
		if (!team) {
			swapImages(backupImages, teamImages);
			
			return;
		}
		
		swapImages(teamImages, backupImages);
		
		for (const [i, mon] of Teams.unpack(team.team).entries()) {
			const {url, pixelated} = Dex.getSpriteData(mon.species, true, {...mon, ...team});
			
			teamImages[i].src = url;
			
			teamImages[i].style.imageRendering = pixelated ? 'pixelated' : 'auto';
			
			teamButtons[i].onclick = () => {
				app.addRoom('teambuilder');
				app.focusRoom('teambuilder');
				
				const {teambuilder} = app.rooms;
				
				teambuilder.edit(teamIndex);
				
				teambuilder.selectPokemon(i);
				
				teambuilder.stats();
				
				// You need to wait for this because it clears the stats
				if (teambuilder.formatResources[team.format] === true) {
					Object.defineProperty(teambuilder.formatResources, team.format, {
						// Avatar change listener
						set(value) {
							delete teambuilder.formatResources[team.format];
							
							teambuilder.formatResources[team.format] = value;
							
							window.setTimeout(() => {
								teambuilder.updateChart();
							}, 0);
						},
						get() {
							return true;
						},
					});
				}
			};
		}
	};
	
	const getTeamImage = (index) => {
		const image = document.createElement('img');
		
		image.style.position = 'absolute';
		image.style.top = `${(index + 1) / 7 * 100}%`;
		image.style.transform = 'translateY(-50%) scaleX(-1)';
		image.style.width = '100%';
		image.style.pointerEvents = 'none';
		image.style.transformOrigin = 'top';
		
		return image;
	};
	
	const getTeamButton = (index) => {
		const button = document.createElement('div');
		
		button.classList.add(IMAGE_BUTTON_CLASS);
		
		button.style.position = 'absolute';
		button.style.top = `${((index + 1) * 2 - 1) / 14 * 100}%`;
		button.style.width = '75%';
		button.style.height = `${1 / 7 * 100}%`;
		button.style.cursor = 'pointer';
		button.style.alignSelf = 'center';
		
		return button;
	};
	
	const getBackupImage = (src) => {
		const image = document.createElement('img');
		
		image.src = src;
		
		image.style.imageRendering = 'pixelated';
		image.style.transform = 'translateX(-2%) scaleX(-1)';
		
		return image;
	};
	
	// Setup container
	
	const container = document.createElement('div');
	
	container.style.height = '100%';
	container.style.background = 'rgba(0, 0, 0, .2)';
	container.style.alignItems = 'stretch';
	container.style.position = 'relative';
	container.style.overflow = 'hidden';
	container.style.flexDirection = 'column';
	container.style.marginLeft = `${GAP_MAIN}px`;
	container.style.borderRadius = '20px';
	container.style.maxWidth = '300px';
	container.style.width = '10%';
	container.style.placeContent = 'stretch space-around';
	container.style.flexDirection = 'column';
	container.style.justifyContent = 'center';
	
	const showContainer = (doShow) => {
		container.style.display = doShow ? 'flex' : 'none';
	};
	
	(new ConditionalDisplay((() => {
		// Minimum screen width when sidebar isn't visible
		const unconditionalWidth = GAP_MAIN * 3 + WIDTH_BATTLE + WIDTH_PM + GAP_PM * 2 + GAP_PMBOX * 2;
		
		// Sidebar width is 10% + GAP_MAIN; (100 / 0.9)% minimum width + GAP_MAIN must be available to accomodate it
		return Math.ceil(unconditionalWidth * (1 / 0.9)) + GAP_MAIN;
	})())).add(showContainer);
	
	// Setup mascots
	
	const backupImages = [];
	
	for (const src of [
		'https://play.pokemonshowdown.com/sprites/gen5ani/meloetta.gif',
		'https://play.pokemonshowdown.com/sprites/gen5ani/meloetta-pirouette.gif',
	]) {
		const image = getBackupImage(src);
		
		container.append(image);
		
		backupImages.push(image);
	}
	
	// Setup team members
	
	const teamImages = [];
	const teamButtons = [];
	
	for (let i = 0; i < 6; ++i) {
		const image = getTeamImage(i);
		const button = getTeamButton(i);
		
		container.append(button, image);
		
		teamImages.push(image);
		teamButtons.push(button);
	}
	
	// Initialise & setup team change listener
	
	update(backupImages, teamImages, teamButtons);
	
	// Add to DOM
	
	const parent = document.querySelector('.leftmenu');
	
	parent.append(container);
})();

/* global app Dex Teams */