// ==UserScript==
// @name [PS] Homepage Enhancements
// @namespace https://greasyfork.org/en/users/1357767-indigeau
// @version 0.3
// @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 [
// Header
['body > #header', [['z-index', '1']]],
// Content
['#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'],
['position', 'absolute'],
['top', '0'],
['padding', `${GAP_MAIN + HEIGHT_STRIP - 1}px 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'],
]],
['.pm-log', [
['max-height', 'calc(100% - 22px) !important'], // Overwrites an element style. header=22px
['min-height', '0'],
['width', '100%'],
['box-sizing', 'border-box'],
// 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-window > .pm-log > *', [ // needs extra specificity to override padding on .pm-log .inner
['width', 'calc(100% + 30px)'],
['box-sizing', 'border-box'],
['padding-right', '35px'],
]],
['.pm-log:has(+ .pm-log-add)', [
['max-height', 'calc(100% - 52px) !important'], // Overwrites an element style. header=22px + .pm-log-add=30px
]],
['.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'],
// Prevent wrapping
['width', '100%'],
['max-width', 'unset'],
]],
['.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'],
]],
// Footer
['.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']]],
['.ps-room.tiny-layout .pmbox > *', [
['margin', `${GAP_PM}px`],
]],
['.tiny-layout .leftmenu', [
['width', '100%'],
['max-width', '100%'],
['padding', `${GAP_MAIN + HEIGHT_STRIP - 1}px 0`],
]],
]) {
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');
}
})();
// 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.display = 'flex';
container.style.alignItems = 'center';
(() => {
const source = document.querySelector('.leftmenu');
const width = 870;
let isShown = source.clientWidth >= width;
(new ResizeObserver(() => {
const doShow = source.clientWidth >= width;
if (doShow !== isShown) {
isShown = doShow;
container.style.display = doShow ? 'flex' : 'none';
}
})).observe(source);
})();
// 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
(() => {
const ID_CONTAINER = 'home-style-sidebar';
const CLASS_IMAGE_CONTAINER = 'home-style-team-container';
const CLASS_IMAGE = 'home-style-image';
const CLASS_WIDE = 'home-style-wide';
const CLASS_TALL = 'home-style-tall';
const CLASS_TINY = 'home-style-tiny';
// The px width of the sidebar in landscape
const WIDTH_SIDEBAR = 100;
// The px thickness of image borders
const SIZE_BORDER = 10;
for (let rule of [
[`#${ID_CONTAINER}`, [
['overflow', 'hidden'],
['margin-left', `${GAP_MAIN}px`],
['border-radius', '20px'],
]],
[`.${CLASS_IMAGE_CONTAINER}`, [
['border-radius', '20px'],
['cursor', 'pointer'],
['position', 'absolute'],
['transition', 'transform 0.2s cubic-bezier(0, 0, 0.3, 1)'],
['width', `calc(100% - ${SIZE_BORDER * 2}px)`],
['height', `${200 / 7}%`],
['text-align', 'center'],
['align-content', 'center'],
['border', `${SIZE_BORDER}px solid rgb(0, 0, 0, 0.2)`],
['color', 'rgb(0, 0, 0, 0.2)'],
['background-color', 'currentcolor'],
]],
...[0, 1, 2, 3, 4, 5].map((row) => [`.${CLASS_IMAGE_CONTAINER}:nth-of-type(${row + 1})`, [
['top', `calc(${row / 7 * 100}% - ${row / 5 * SIZE_BORDER * 2}px)`],
]]),
[`.${CLASS_IMAGE_CONTAINER}:hover ~ .${CLASS_IMAGE_CONTAINER}`, [
['transform-origin', 'top'],
['transform', `translateY(calc(50% - ${20 - SIZE_BORDER}px))`],
]],
[`.${CLASS_IMAGE}`, [
['height', '100%'],
['padding', '1vh 1vw'],
['box-sizing', 'border-box'],
['filter', 'drop-shadow(0 0 1px black)'],
]],
[`.${CLASS_IMAGE}`, [
['width', '100%'],
['object-fit', 'contain'],
['image-rendering', 'pixelated'],
['pointer-events', 'none'],
]],
// landscape
[`:not(.${CLASS_TALL}) > #${ID_CONTAINER}`, [
['position', 'relative'],
['width', 'calc(50% - 280px)'],
['max-width', `${WIDTH_BATTLE}px`],
['min-width', `${WIDTH_SIDEBAR}px`],
]],
// portrait wide
[`.${CLASS_TALL} > #${ID_CONTAINER}`, [
['position', 'absolute'],
['bottom', `${GAP_MAIN + HEIGHT_STRIP - 1}px`],
['left', '0'],
['height', `calc(50% - ${GAP_MAIN + HEIGHT_STRIP - 1}px)`],
['width', `${WIDTH_BATTLE}px`],
]],
[`.${CLASS_TALL} > .mainmenu`, [
['height', `calc(50% - ${GAP_MAIN}px)`],
]],
// thin
[`:not(.${CLASS_WIDE}) > #${ID_CONTAINER}`, [
['display', 'none'],
]],
[`.${CLASS_TALL}:not(.${CLASS_WIDE}) > .mainmenu, .${CLASS_TINY} > .mainmenu`, [
['width', `calc(100% - ${GAP_MAIN * 2}px)`],
]],
[`.${CLASS_TALL}:not(.${CLASS_WIDE}) > .activitymenu > .pmbox`, [
['position', 'absolute'],
['bottom', `${GAP_MAIN}px`],
['left', `${GAP_MAIN}px`],
['height', `calc(50% - ${GAP_MAIN}px)`],
['width', `calc(100% - ${GAP_MAIN * 2}px)`],
]],
// tiny
[`.${CLASS_TINY} > .activitymenu > .pmbox`, [
['display', 'none'],
]],
]) {
styleSheet.insertRule(`${rule[0]}{${rule[1].map(([property, value]) => `${property}:${value};`).join('')}}`);
}
// Helpers
const update = (() => {
const styleSheet = (() => {
const styleElement = document.createElement('style');
document.head.appendChild(styleElement);
return styleElement.sheet;
})();
const onClick = (team, teamIndex, {species}, monIndex, {ctrlKey}) => {
let roomWasClosed = !window.app.rooms['teambuilder'];
if (roomWasClosed) {
window.app.addRoom('teambuilder');
}
const {teambuilder} = window.app.rooms;
teambuilder.edit(teamIndex);
teambuilder.selectPokemon(monIndex);
if (ctrlKey) {
window.open(teambuilder.smogdexLink(species));
if (roomWasClosed) {
window.app.removeRoom('teambuilder');
}
return;
}
window.app.focusRoom('teambuilder');
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 fillEmpties = (images, empties) => {
for (const i of empties) {
const image = images[i];
image.parentElement.style.removeProperty('color');
image.parentElement.onclick = null;
image.style.display = 'none';
}
};
const setImage = (() => {
const getLeft = (data, width) => {
for (let i = 3; i < data.length; i += 4) {
for (let j = i; j < data.length; j += (width * 4)) {
if (data[j] > 0) {
return (i - 3) / 4;
}
}
}
return null;
};
const getTop = (data, rowLength) => {
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) {
return Math.floor(i / rowLength);
}
}
return null;
};
const getRight = (data, rowLength) => {
for (let i = data.length - 1; i >= 3; i -= 4) {
for (let j = i; j >= 3; j -= rowLength) {
if (data[j] > 0) {
return (rowLength - data.length + i + 1) / 4;
}
}
}
return null;
};
const getBottom = (data, rowLength) => {
for (let i = data.length - 1; i >= 3; i -= 4) {
if (data[i] > 0) {
return Math.floor(i / rowLength) + 1;
}
}
return null;
};
return async (canvas, src) => {
const ctx = canvas.getContext('2d', {willReadFrequently: true, alpha: true});
const image = new Image();
await new Promise((resolve) => {
image.onload = resolve;
image.src = src;
});
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
const {data, height, width} = ctx.getImageData(0, 0, canvas.width, canvas.height);
const rowLength = width * 4;
const left = getLeft(data, rowLength) ?? 0;
const top = getTop(data, rowLength) ?? 0;
const right = getRight(data, rowLength) ?? width;
const bottom = getBottom(data, rowLength) ?? height;
canvas.width = right - left;
canvas.height = bottom - top;
// Blur lines & focus main colours
ctx.filter = 'saturate(2) blur(10px) saturate(3) saturate(0.6)';
ctx.drawImage(image, left, top, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
canvas.parentElement.style.color = `rgba(${(new window.ColorThief()).getColor(canvas).join(', ')}, 0.8)`;
ctx.reset();
ctx.drawImage(image, left, top, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
};
})();
return (container, teamImages, 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(container, teamImages, false);
},
get() {
return value;
},
});
}
(new MutationObserver(() => {
update(container, teamImages, false);
})).observe(button.parentElement, {
characterData: true,
childList: true,
subtree: true,
});
}
const empties = [0, 1, 2, 3, 4, 5];
const teamIndex = Number.parseInt(button.value);
for (let i = styleSheet.cssRules.length - 1; i >= 0; --i) {
styleSheet.deleteRule(i);
}
if (Number.isInteger(teamIndex)) {
const teamName = button.firstChild.innerText;
const teamText = window.Storage.teams.find(({name}) => name === teamName);
if (teamText) {
const team = window.Teams.unpack(teamText.team);
for (let i = team.length - 1; i >= 0; --i) {
const mon = team[i];
if (!mon.species) {
continue;
}
empties.splice(i, 1);
const {url} = window.Dex.getSpriteData(mon.species, true, {...mon, ...teamText, noScale: true});
const canvas = teamImages[i];
canvas.style.removeProperty('display');
setImage(canvas, url);
teamImages[i].parentElement.ondragstart = (() => {
const cropped = {
url: canvas.toDataURL(),
image: new Image(),
};
const raw = {
url,
image: new Image(),
};
cropped.image.src = cropped.url;
raw.image.src = raw.url;
return ({dataTransfer, ctrlKey}) => {
const {url, image} = ctrlKey ? cropped : raw;
dataTransfer.setData('text/plain', url);
dataTransfer.setDragImage(image, image.width / 2, image.height / 2);
};
})();
teamImages[i].parentElement.onclick = onClick.bind(null, team, teamIndex, mon, i);
}
}
}
fillEmpties(teamImages, empties);
};
})();
const getTeamImage = () => {
const container = document.createElement('div');
container.classList.add(CLASS_IMAGE_CONTAINER);
container.draggable = true;
const image = document.createElement('canvas');
image.classList.add(CLASS_IMAGE);
container.append(image);
return image;
};
// Drag & drop into pmwindows
(() => {
const pmbox = window.app.rooms[''].$pmBox[0];
pmbox.addEventListener('drop', (event) => {
event.stopPropagation();
const isInWindow = (id) => event.target.matches(`.pm-window-${id} *`) || event.target.classList.contains(`.pm-window-${id}`);
if (event.target.matches('textarea')) {
window.setTimeout(() => {
event.target.dispatchEvent(new KeyboardEvent('keyup')); // force a resize
}, 0);
return;
}
let name;
if (event.target.isSameNode(pmbox) || isInWindow('')) {
name = '~';
} else if (isInWindow(window.app.user.get('userid'))) {
name = window.app.user.get('name');
} else {
return;
}
const room = window.app.rooms[''];
const message = `/raw <img src="${event.dataTransfer.getData('text/plain')}" class="pixelated" style="vertical-align: middle;" />`;
room.addPM(name, message, name);
room.openPM(name).find('textarea[name=message]').focus(); // remove .pm-notifying
});
})();
// Setup container
const container = document.createElement('div');
container.id = ID_CONTAINER;
// Setup dynamic sizing classes
(() => {
const sizes = {
portrait: 900,
portraitSmall: 450,
};
sizes.portraitWide = GAP_MAIN * 3 + WIDTH_BATTLE + WIDTH_PM + GAP_PM * 2 + GAP_PMBOX * 2;
sizes.landscapeWide = sizes.portraitWide + WIDTH_SIDEBAR + GAP_MAIN;
sizes.portraitWide = Math.min(window.MainMenuRoom.prototype.bestWidth - 1, sizes.portraitWide);
const source = document.querySelector('.leftmenu');
(new ResizeObserver(() => {
if (source.clientHeight < sizes.portraitSmall && source.clientWidth < sizes.portraitWide) {
// tiny
source.classList.add(CLASS_TINY);
source.classList.remove(CLASS_TALL);
source.classList.remove(CLASS_WIDE);
return;
}
if (source.clientHeight < sizes.portraitSmall || (source.clientHeight < sizes.portrait && source.clientWidth > sizes.portraitWide)) {
// landscape
source.classList.remove(CLASS_TALL);
source.classList.remove(CLASS_TINY);
source.classList[source.clientWidth > sizes.landscapeWide ? 'add' : 'remove'](CLASS_WIDE);
return;
}
// portrait
source.classList.add(CLASS_TALL);
source.classList.remove(CLASS_TINY);
source.classList[source.clientWidth > sizes.portraitWide ? 'add' : 'remove'](CLASS_WIDE);
})).observe(source);
})();
// Setup team members
const teamImages = [];
for (let i = 0; i < 6; ++i) {
const image = getTeamImage();
container.append(image.parentElement);
teamImages.push(image);
}
// Initialise & setup team change listener
update(container, teamImages);
// 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();
}
})();