[PS] Homepage Enhancements

Improves the Pokemon Showdown homepage.

Auteur
indigeau
Installations quotidiennes
1
Installations (total)
4
Notes
0 0 0
Version
0.12
Créé
03/09/2024
Mis à jour
20/09/2024
Licence
GNU GPLv3
S'applique à

// ==UserScript== // @name [PS] Homepage Enhancements // @namespace https://greasyfork.org/en/users/1357767-indigeau // @version 0.9 // @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 GAPMAIN = 20; // The gap between .pm-window elements const GAPPM = 10; // The gap between the edge of .pmbox and .pm-window const GAPPMBOX = 10; // The height of #header const HEIGHTHEADER = 50; // The height of .maintabbarbottom (height=6px + top or bottom border=1px) const HEIGHTSTRIP = 7; // The width of .mainmenu const WIDTHBATTLE = 270; // The width of .pm-window const WIDTH_PM = 270;

const room = window.app.rooms[''];

const addResizeListener = (() => {
    const TIMEOUT_DURATION = 20;

    return (listener, target) => {
        let isQueueing = false;

        const callback = () => {
            if (isQueueing) {
                return;
            }

            isQueueing = true;

            window.setTimeout(() => {
                isQueueing = false;

                listener();
            }, TIMEOUT_DURATION);
        };

        if (target) {
            (new ResizeObserver(callback)).observe(target);
        } else {
            window.addEventListener('resize', callback);
        }
    };
})();

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:hover', [['box-shadow', 'inset rgba(0, 0, 0, 0.09) 0 0 0 12pt']]],
        ['.mainmenu > .menugroup:not(:first-child) p > button:not(:hover)', [['box-shadow', 'inset rgba(0, 0, 0, 0.14) 0 0 0 12pt']]],
        ['.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;

        addResizeListener(() => {
            const doShow = source.clientWidth >= width;

            if (doShow !== isShown) {
                isShown = doShow;

                container.style.display = doShow ? 'flex' : 'none';
            }
        }, source);

        container.style.display = isShown ? 'flex' : 'none';
    })();

    // 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', () => {
        room.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_SIDEBAR = 'home-style-sidebar';
    const CLASS_CONTAINER = 'home-style-container';
    const CLASS_IMAGE = 'home-style-image';
    const CLASS_BORDER_LEFT = 'home-style-border-l';
    const CLASS_BORDER_RIGHT = 'home-style-border-r';
    const CLASS_BACKGROUND = 'home-style-background';

    const CLASS_WIDE = 'home-style-wide';
    const CLASS_TALL = 'home-style-tall';
    const CLASS_TINY = 'home-style-tiny';

    const CLASS_SPLIT = 'home-style-col';

    // The px width of the sidebar in landscape
    const WIDTH_SIDEBAR = 100;
    // The px thickness of image borders
    const SIZE_BORDER = 8;
    // The angle of slant in split view
    const SKEW = 15;

    for (let rule of [
        // backgrounds
        [`.${CLASS_BORDER_LEFT}, .${CLASS_BORDER_RIGHT}`, [
            ['border-color', 'rgb(0, 0, 0, 0.2)'],
            ['border-style', 'solid'],
            ['position', 'absolute'],
            ['height', '100%'],
            ['box-sizing', 'border-box'],
        ]],
        [`:not(.${CLASS_SPLIT}) > .${CLASS_CONTAINER} > .${CLASS_BORDER_LEFT},
            :not(.${CLASS_SPLIT}) > .${CLASS_CONTAINER} > .${CLASS_BORDER_RIGHT}`, [
            ['width', '50%'],
        ]],
        [`.${CLASS_BORDER_LEFT}, .${CLASS_CONTAINER}:nth-child(2n) > .${CLASS_BACKGROUND}`, [
            ['left', '0'],
        ]],
        [`.${CLASS_BORDER_RIGHT}, .${CLASS_CONTAINER}:nth-child(2n + 1) > .${CLASS_BACKGROUND}`, [
            ['right', '0'],
        ]],
        [`.${CLASS_CONTAINER}:first-child > .${CLASS_BORDER_LEFT}`, [
            ['border-top-left-radius', '20px'],
        ]],
        [`:not(.${CLASS_SPLIT}) > .${CLASS_CONTAINER}:first-child > .${CLASS_BORDER_RIGHT},
            .${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2) > .${CLASS_BORDER_RIGHT}`, [
            ['border-top-right-radius', '20px'],
        ]],
        [`:not(.${CLASS_SPLIT}) > .${CLASS_CONTAINER}:last-child > .${CLASS_BORDER_LEFT},
            .${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(5) > .${CLASS_BORDER_LEFT}`, [
            ['border-bottom-left-radius', '20px'],
        ]],
        [`.${CLASS_CONTAINER}:last-child > .${CLASS_BORDER_RIGHT}`, [
            ['border-bottom-right-radius', '20px'],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2n + 1) > .${CLASS_BORDER_LEFT},
            .${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2n) > .${CLASS_BORDER_RIGHT},
            .${CLASS_SPLIT} > .${CLASS_CONTAINER} > .${CLASS_IMAGE}`, [
            ['transform', `skewX(${-SKEW}deg)`],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER} > .${CLASS_BORDER_LEFT}`, [
            ['border-width', `${SIZE_BORDER}px 0 ${SIZE_BORDER}px ${SIZE_BORDER}px`],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER} > .${CLASS_BORDER_RIGHT}`, [
            ['border-width', `${SIZE_BORDER}px ${SIZE_BORDER}px ${SIZE_BORDER}px 0`],
        ]],
        [`:not(.${CLASS_SPLIT}) > .${CLASS_CONTAINER} > .${CLASS_BORDER_LEFT}`, [
            ['border-width', `${SIZE_BORDER}px 0 ${SIZE_BORDER}px ${SIZE_BORDER}px`],
        ]],
        [`:not(.${CLASS_SPLIT}) > .${CLASS_CONTAINER} > .${CLASS_BORDER_RIGHT}`, [
            ['border-width', `${SIZE_BORDER}px ${SIZE_BORDER}px ${SIZE_BORDER}px 0`],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2n + 1) > .${CLASS_BORDER_LEFT}`, [
            ['width', `calc(${100 - SKEW}% - ${SIZE_BORDER}px)`],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2n) > .${CLASS_BORDER_RIGHT}`, [
            ['width', `calc(${100 + SKEW}% + ${SIZE_BORDER}px)`],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2n + 1) > .${CLASS_BORDER_RIGHT},
            .${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2n) > .${CLASS_BORDER_LEFT}`, [
            ['width', '0'],
            ['height', `calc(100% - ${SIZE_BORDER * 2 - 0.3}px)`],
            ['top', `${SIZE_BORDER}px`],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER} > .${CLASS_IMAGE}`, [
            ['padding', `${SIZE_BORDER + 4}px`],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2n + 1) > .${CLASS_IMAGE}`, [
            ['padding-right', `calc(${SKEW}% - ${SIZE_BORDER * (100 - SKEW) / 100}px)`],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2n) > .${CLASS_IMAGE}`, [
            ['padding-left', `calc(${SKEW}% - ${SIZE_BORDER * (100 - SKEW) / 100}px)`],
        ]],
        [`:not(.${CLASS_SPLIT}) > .${CLASS_CONTAINER} > .${CLASS_IMAGE}`, [
            ['padding', `${SIZE_BORDER}px`],
        ]],
        [`#${ID_SIDEBAR}`, [
            ['overflow', 'hidden'],
            ['margin-left', `${GAP_MAIN}px`],
            ['border-radius', '20px'],
            ['display', 'grid'],
        ]],
        [`#${ID_SIDEBAR}:not(.${CLASS_SPLIT})`, [
            ['grid-auto-rows', `${100 / 6}%`],
            ['grid-auto-columns', '100%'],
        ]],
        [`#${ID_SIDEBAR}.${CLASS_SPLIT}`, [
            ['grid-auto-rows', `${100 / 3}%`],
            ['grid-auto-columns', '50%'],
        ]],
        [`#${ID_SIDEBAR}.${CLASS_SPLIT} > .${CLASS_CONTAINER}:nth-child(2)`, [
            ['grid-column', '2'],
        ]],
        [`.${CLASS_CONTAINER}`, [
            ['cursor', 'pointer'],
            ['width', '100%'],
            ['height', '100%'],
            ['color', 'rgb(0, 0, 0, 0.2)'],
            ['position', 'relative'],
        ]],
        [`.${CLASS_CONTAINER} > *`, [
            ['pointer-events', 'none'],
        ]],
        [`.${CLASS_BACKGROUND}`, [
            ['position', 'absolute'],
            ['width', `calc(${100 + SKEW}% + ${SIZE_BORDER}px)`],
            ['height', '100%'],
            ['background-color', 'currentcolor'],
        ]],
        [`.${CLASS_SPLIT} > .${CLASS_CONTAINER}`, [
            ['transform', `skewX(${SKEW}deg)`],
        ]],
        [`.${CLASS_IMAGE}`, [
            ['height', '100%'],
            ['box-sizing', 'border-box'],
            ['filter', 'drop-shadow(0 0 1px black)'],
            ['width', '100%'],
            ['object-fit', 'contain'],
            ['image-rendering', 'pixelated'],
            ['pointer-events', 'none'],
        ]],
        [`.${CLASS_CONTAINER}:hover > .${CLASS_BACKGROUND}`, [
            ['filter', 'brightness(1.15)'],
        ]],
        [`.${CLASS_CONTAINER}:hover > .${CLASS_IMAGE}`, [
            ['filter', 'drop-shadow(0px 0px 3px black)'],
        ]],
        // landscape
        [`:not(.${CLASS_TALL}) > #${ID_SIDEBAR}`, [
            ['position', 'relative'],
            ['width', 'calc(50% - 280px)'],
            ['max-width', `${WIDTH_BATTLE}px`],
            ['min-width', `${WIDTH_SIDEBAR}px`],
        ]],
        // portrait wide
        [`.${CLASS_TALL} > #${ID_SIDEBAR}`, [
            ['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_SIDEBAR}`, [
            ['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('')}}`);
    }

    // Setup elements

    const sidebar = document.createElement('div');

    sidebar.id = ID_SIDEBAR;

    const canvases = [];

    for (let i = 0; i < 6; ++i) {
        const container = document.createElement('div');
        const background = document.createElement('div');
        const backgroundL = document.createElement('div');
        const backgroundR = document.createElement('div');
        const canvas = document.createElement('canvas');

        container.classList.add(CLASS_CONTAINER);
        background.classList.add(CLASS_BACKGROUND);
        backgroundL.classList.add(CLASS_BORDER_LEFT);
        backgroundR.classList.add(CLASS_BORDER_RIGHT);
        canvas.classList.add(CLASS_IMAGE);

        container.draggable = true;

        container.append(background, backgroundL, backgroundR, canvas);
        sidebar.append(container);

        canvases.push(canvas);
    }

    // Helpers

    const updateRatio = (() => {
        let ratio;

        return (newRatio) => {
            if (newRatio !== undefined) {
                ratio = newRatio;
            }

            const singleRatio = (sidebar.clientWidth - SIZE_BORDER * 2) / ((sidebar.clientHeight - SIZE_BORDER * 12) / 6);
            const doubleRatio = ((sidebar.clientWidth - SIZE_BORDER * 2) / 2) / ((sidebar.clientHeight - SIZE_BORDER * 6) / 3);

            sidebar.classList[ratio / doubleRatio < singleRatio / ratio ? 'add' : 'remove'](CLASS_SPLIT);
        };
    })();

    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 getOnDrag = (image, canvas) => {
            const raw = {
                url: image.src,
                image,
            };
            const cropped = {
                url: canvas.toDataURL(),
                image: new Image(),
            };

            raw.image.src = raw.url;
            cropped.image.src = cropped.url;

            return ({dataTransfer, ctrlKey}) => {
                const {url, image} = ctrlKey ? cropped : raw;

                dataTransfer.setData('text/plain', url);
                dataTransfer.setDragImage(image, image.width / 2, image.height / 2);
            };
        };

        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;
            };

            const getColour = (() => {
                const [getHash, getRGB] = (() => {
                    const bits = 3;
                    const shifts = 8 - bits;
                    const correction = 1 << (bits - 1);

                    return [
                        (r, g, b) => (r >> shifts) + ((g >> shifts) << bits) + ((b >> shifts) << (bits * 2)),
                        (hash) => ([
                            ((hash % (1 << bits)) << shifts) + correction,
                            (((hash >> bits) % (1 << bits)) << shifts) + correction,
                            (((hash >> (bits * 2)) % (1 << bits)) << shifts) + correction,
                        ]),
                    ];
                })();

                const getColourCounts = (data) => {
                    const counts = new Map();

                    for (let i = 3; i < data.length; i += 4) {
                        if (data[i] === 0) {
                            continue;
                        }

                        const hash = getHash(data[i - 3], data[i - 2], data[i - 1]);

                        counts.set(hash, counts.has(hash) ? counts.get(hash) + 1 : 1);
                    }

                    return counts;
                };

                return ({data}) => getRGB(getColourCounts(data).entries().reduce(
                    (best, current) => best[1] > current[1] ? best : current,
                    [null, -1],
                )[0]);
            })();

            const getData = (canvas, ctx, filter = 'saturate(1.5) blur(3px) saturate(1.5) blur(3px) saturate(1.5)', crop = 5, sections = 3, expand = 5) => {
                const canvasMain = new OffscreenCanvas(canvas.width, canvas.height);
                const ctxMain = canvasMain.getContext('2d', {alpha: true});

                ctxMain.drawImage(canvas, 0, 0, canvas.width, canvas.height, crop, crop, canvas.width - crop * 2, canvas.height - crop * 2);

                ctxMain.globalCompositeOperation = 'source-atop';

                ctxMain.drawImage(canvas, 0, 0, canvas.width, canvas.height, -crop, -crop, canvas.width + crop * 2, canvas.height + crop * 2);

                ctx.filter = 'brightness(0)';
                ctx.globalCompositeOperation = 'copy';

                ctx.drawImage(canvasMain, 0, 0, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);

                ctx.globalCompositeOperation = 'source-atop';
                ctx.filter = filter;

                const sectionHeight = canvas.height / sections;
                const sectionWidth = canvas.width / sections;

                for (let i = 0; i < sections; ++i) {
                    ctx.drawImage(canvasMain, 0, sectionHeight * i, canvas.width, sectionHeight, 0, sectionHeight * i - expand, canvas.width, sectionHeight + expand * 2);
                }

                for (let i = 0; i < sections; ++i) {
                    ctx.drawImage(canvasMain, sectionWidth * i, 0, sectionWidth, canvas.height, sectionWidth * i - expand, 0, sectionWidth + expand * 2, canvas.height);
                }

                ctx.globalCompositeOperation = 'copy';
                ctx.filter = 'none';

                return ctx.getImageData(crop, crop, canvas.width - crop * 2, canvas.height - crop * 2);
            };

            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;

                ctx.drawImage(image, left, top, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);

                canvas.parentElement.style.color = `rgb(${getColour(getData(canvas, ctx)).join(', ')})`;

                ctx.drawImage(image, left, top, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);

                canvas.style.removeProperty('display');

                return image;
            };
        })();

        return (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(false);
                        },
                        get() {
                            return value;
                        },
                    });
                }

                (new MutationObserver(() => {
                    update(false);
                })).observe(button.parentElement, {
                    characterData: true,
                    childList: true,
                    subtree: true,
                });
            }

            const empties = [0, 1, 2, 3, 4, 5];
            const teamIndex = Number.parseInt(button.value);
            const promises = [];

            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 = canvases[i];

                        canvas.style.removeProperty('display');

                        canvas.parentElement.onclick = onClick.bind(null, team, teamIndex, mon, i);

                        promises.push(setImage(canvas, url).then((image) => {
                            canvas.parentElement.ondragstart = getOnDrag(image, canvas);

                            return canvas.width / canvas.height;
                        }));
                    }
                }
            }

            fillEmpties(canvases, empties);

            Promise.all(promises).then((ratios) => {
                updateRatio(ratios.length === 0 ? 1 : (ratios.reduce((total, next) => total + next, 0) / ratios.length));
            });
        };
    })();

    // Drag & drop into pmwindows

    (() => {
        const pmbox = room.$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 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
        });
    })();

    // Initialise & setup team change listener

    update();

    // Setup responsive layout

    (() => {
        const sizes = {
            portrait: 783,
            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.landscapeWidest = (WIDTH_BATTLE + 280) / 0.5; // ['width', 'calc(50% - 280px)']

        const source = document.querySelector('.leftmenu');

        const resizeLeftRight = () => {
            if (source.classList.contains(CLASS_TALL) || window.innerWidth - window.Room.prototype.bestWidth < sizes.landscapeWide) {
                room.bestWidth = sizes.portraitWide;
            } else {
                room.bestWidth = Math.min(sizes.landscapeWidest, window.innerWidth - window.Room.prototype.bestWidth);
            }

            // prevents `this.curRoom.show(, leftMin)` from affecting width
            room.tinyWidth = room.bestWidth;

            window.app.updateLayout();
        };

        addResizeListener(resizeLeftRight);

        resizeLeftRight();

        addResizeListener(() => {
            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.clientWidth >= sizes.landscapeWidest || (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);

                updateRatio();

                resizeLeftRight();

                return;
            }

            // portrait
            source.classList.add(CLASS_TALL);
            source.classList.remove(CLASS_TINY);

            source.classList[source.clientWidth >= sizes.portraitWide ? 'add' : 'remove'](CLASS_WIDE);

            room.bestWidth = sizes.portraitWide;
            room.tinyWidth = sizes.portraitWide;
            window.app.updateLayout();

            updateRatio();
        }, source);
    })();

    // Add to DOM

    const parent = document.querySelector('.leftmenu');

    parent.append(sidebar);
})();

};

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

})();