[PS] Homepage Enhancements

Improves the Pokemon Showdown homepage.

目前為 2024-09-06 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
	}
})();