[PS] Homepage Enhancements

Improves the Pokemon Showdown homepage.

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

// ==UserScript==
// @name        [PS] Homepage Enhancements
// @namespace   https://greasyfork.org/en/users/1357767-indigeau
// @version     0.1
// @description Improves the 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==

const main = () => {
	// 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%'],
				['max-width', '100%'], // for when .ps-room.tiny-layout overrwrites width to auto
				['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'],
				['overflow', 'hidden'],
			]],
			['.ps-room.tiny-layout .pmbox > *', [
				['margin', `${GAP_PM}px`],
			]],
			['.pm-log', [
				['max-height', 'calc(100% - 22px) !important'], // Overwrites an element style. header=22px
				['min-height', '0'],
				// Fixes an issue with the element failing to lose its scrollbar when it no longer needs it
				['scrollbar-gutter', 'stable'],
				['overflow-x', 'hidden'],
			]],
			// Handle parent's scrollbar-gutter value
			['.pm-log > *', [
				['width', 'calc(100% + 30px)'],
				['box-sizing', 'border-box'],
				['padding-right', '35px'],
			]],
			['.pm-log:has(+ .pm-log-add)', [
				['max-height', 'calc(100% - 53px) !important'], // Overwrites an element style. header=22px + .pm-log-add=31px
			]],
			['.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'],
				['background', 'rgba(0, 0, 0, .2)'],
				['border-radius', '20px'],
				['justify-content', 'space-evenly'],
				['flex-flow', 'wrap'],
			]],
			['.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'],
				['text-shadow', 'rgb(255 255 255) 0px 0px 3px, rgb(255 255 255) 0px 0px 3px, rgb(255 255 255) 0px 0px 3px'],
				['color', 'black'],
			]],
			['.mainmenufooter', [
				['height', `${HEIGHT_HEADER}px`],
				['bottom', `-${HEIGHT_HEADER}px`],
				['width', '100%'],
				['left', '0'],
				['display', 'flex'],
				['flex-direction', 'row-reverse'],
			]],
			['.bgcredit.roomtab.button', [
				['overflow-y', 'hidden'],
				['display', 'flex'],
				['align-items', 'center'],
				['max-width', '150px'],
			]],
			['.bgcredit.roomtab.button *', [
				['border', 'none'],
			]],
			['.bgcredit.roomtab.button a', [
				['padding', '0'],
				['margin', '0'],
			]],
			['.bgcredit.roomtab.button > small', [
				['font-size', '12pt'],
				['width', '100%'],
			]],
			['.bgcredit.roomtab.button small', [
				['display', 'block'],
				['text-overflow', 'ellipsis'],
				['contain', 'content'],
				['white-space', 'pre'],
			]],
			['.bgcredit.roomtab.button:empty', [['display', 'none']]],
			['.mainmenufooter > small', [
				['flex-grow', '1'],
				['display', 'flex'],
				['font-size', '0'],
				['border-top', '1px solid #34373b'],
				['justify-content', 'center'],
				['max-width', '100%'],
				['contain', 'size'],
			]],
			['.dark > body .mainmenufooter a, .dark > body .mainmenufooter a:visited', [['color', '#fff']]],
			['.dark > body .mainmenufooter > small a, .dark > body .mainmenufooter > .bgcredit', [
				['box-shadow', 'inset 0.5px -0.5px 1px 0.5px rgba(255, 255, 255, .5)'],
			]],
			[':not(.dark) > body .mainmenufooter a, :not(.dark) > body .mainmenufooter a:visited', [['color', '#222']]],
			[':not(.dark) > body .mainmenufooter > small a, :not(.dark) > body .mainmenufooter > .bgcredit', [
				['box-shadow', 'inset 0.5px -0.5px 1px 0.5px rgba(255, 255, 255, .5)'],
			]],
			['.mainmenufooter a, .bgcredit.roomtab.button', [
				['font-size', '12pt'],
				['text-align', 'center'],
				['text-decoration', 'none'],
				['border-radius', '0'],
				['margin', '0'],
				['padding', '4px 12px'],
			]],
			['.mainmenufooter > small > a', [
				['height', '28px'],
			]],
			['.mainmenufooter a:hover', [['text-decoration', 'none']]],
			['.mainmenufooter > small a:first-of-type', [['border-bottom-left-radius', '5px']]],
			['.mainmenufooter > small a:last-of-type', [
				['border-bottom-right-radius', '5px'],
				['overflow-x', 'hidden'],
				['white-space', 'pre'],
				['text-overflow', 'ellipsis'],
			]],
			// tiny-layout
			['.tiny-layout .mainmenufooter a', [['font-size', '10px']]],
			['.tiny-layout .bgcredit.roomtab.button', [['display', 'none']]],
		]) {
			styleSheet.insertRule(`${rule[0]}{${rule[1].map(([property, value]) => `${property}:${value};`).join('')}}`);
		}
		
		for (const button of [...document.querySelectorAll('.mainmenufooter > small > a'), ...document.querySelectorAll('.mainmenufooter > .bgcredit')]) {
			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(870)).add(showContainer);
		
		// Setup text
		
		const textCredit = document.createElement('span');
		
		textCredit.style.marginLeft = '6px';
		textCredit.style.whiteSpace = 'pre';
		
		textCredit.innerText = 'Script by ';
		
		const textName = document.createElement('span');
		
		textName.style.color = '#258f14';
		textName.style.fontWeight = 'bold';
		
		textName.innerText = 'indigeau';
		
		// Setup pm button
		
		const psButton = getButton();
		
		psButton.src = 'https://www.google.com/s2/favicons?sz=64&domain=pokemonshowdown.com';
		
		psButton.addEventListener('click', () => {
			window.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, textCredit, textName);
		
		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 + *`, [
				['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) {
				const {data} = window.Storage.prefs;
				
				for (const property of ['bwgfx', 'noanim', 'nopastgens']) {
					let value = data[property] ?? false;
					
					Object.defineProperty(data, property, {
						set(_value) {
							value = _value;
							
							update(backupImages, teamImages, teamButtons, false);
						},
						get() {
							return value;
						},
					});
				}
				
				(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, ...teamButtons]);
				
				return;
			}
			
			const teamName = button.firstChild.innerText;
			const team = window.Storage.teams.find(({name}) => name === teamName);
			
			if (!team) {
				swapImages(backupImages, [...teamImages, ...teamButtons]);
				
				return;
			}
			
			swapImages([...teamImages, ...teamButtons], backupImages);
			
			for (const [i, mon] of window.Teams.unpack(team.team).entries()) {
				const {url, pixelated, h, w} = window.Dex.getSpriteData(mon.species, true, {...mon, ...team, noScale: true});
				const image = teamImages[i];
				
				if (h > w) {
					image.style.scale = `${w / h}`;
				} else {
					image.style.removeProperty('scale');
				}
				
				image.src = url;
				
				// pixelated just always looks better
				image.style.imageRendering = pixelated ? 'pixelated' : 'pixelated';
				
				teamButtons[i].onclick = () => {
					window.app.addRoom('teambuilder');
					window.app.focusRoom('teambuilder');
					
					const {teambuilder} = window.app.rooms;
					
					teambuilder.edit(teamIndex);
					
					teambuilder.selectPokemon(i);
					
					teambuilder.stats();
					
					// You need to listen for this assignment 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 container = document.createElement('div');
			const image = document.createElement('img');
			
			container.style.position = 'absolute';
			container.style.top = `${(index + 1) / 7 * 100}%`;
			container.style.transform = 'translateY(-50%) scaleX(-1)';
			container.style.width = '100%';
			container.style.pointerEvents = 'none';
			container.style.transformOrigin = 'top';
			
			image.style.width = '100%';
			
			container.append(image);
			
			return {container, 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.container);
			
			teamImages.push(image.image);
			teamButtons.push(button);
		}
		
		// Initialise & setup team change listener
		
		update(backupImages, teamImages, teamButtons);
		
		// Add to DOM
		
		const parent = document.querySelector('.leftmenu');
		
		parent.append(container);
	})();
};

// Dealing with firefox being restrictive
(() => {
	const context = typeof unsafeWindow === 'undefined' ? window : unsafeWindow;
	
	context._test = {};
	
	const isAccessDenied = (() => {
		try {
		// Firefox throws `Error: Permission denied to access property "_"` which messes with the sidebar code
		// e.g. window.Dex.getSpriteData('', true, {}) throws a permission denied error when showdown reads options
			window.eval('_test._');
		} catch (e) {
			return true;
		}
		
		return false;
	})();
	
	delete context._test;
	
	if (isAccessDenied) {
		window.eval(`(${main.toString()})()`);
	} else {
		main();
	}
})();