Internet Roadtrip - Custom Steering Wheel and co.

Allows you to customize the steering wheel image, among other images, in neal.fun/internet-roadtrip

// ==UserScript==
// @name         Internet Roadtrip - Custom Steering Wheel and co.
// @description  Allows you to customize the steering wheel image, among other images, in neal.fun/internet-roadtrip
// @namespace    me.netux.site/user-scripts/custom-steering-wheel
// @match        https://neal.fun/internet-roadtrip/*
// @icon         https://neal.fun/favicons/internet-roadtrip.png
// @version      2.9.0
// @author       Netux
// @license      MIT
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.deleteValue
// @grant        GM.registerMenuCommand
// @run-at       document-end
// @require      https://cdn.jsdelivr.net/combine/npm/@violentmonkey/dom@2,npm/@violentmonkey/[email protected]
// @require      https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==

/* globals IRF, VM, Howl */

(async () => {
  const SUPPORTS_ELEMENT_COMPUTED_STYLE_MAP = Element.prototype.computedStyleMap != null;

  const UNIT_TO_CSS_UNIT_MAP = {
    'number': '',
    'percentage': '%'
  };
  const CSS_UNIT_TO_UNIT_MAP = Object.fromEntries(
    Object.entries(UNIT_TO_CSS_UNIT_MAP).map(([key, value]) => [value, key])
  );

  function cssValueFromRawValue(cssValue) {
    const numericValueMatch = cssValue.match(/^(?<value>[-+]?\d*\.?\d+)(?<unit>[^\d]+)$/);
    if (numericValueMatch) {
      return new window.CSSUnitValue(parseFloat(numericValueMatch.groups.value), numericValueMatch.groups.unit);
    } else {
      return new window.CSSKeywordValue(cssValue);
    }
  }

  // polyfills
  window.CSSUnitValue = (() => {
    if (!window.CSSUnitValue) {
      window.CSSUnitValue = class {
        constructor(value, unit) {
          this.value = value;
          this.unit = CSS_UNIT_TO_UNIT_MAP[unit] || unit;
        }

        toString() {
          return `${this.value}${UNIT_TO_CSS_UNIT_MAP[this.unit] || this.unit || ''}`;
        }
      }
    }

    return window.CSSUnitValue;
  })();
  window.CSSKeywordValue = (() => {
    if (!window.CSSKeywordValue) {
      window.CSSKeywordValue = class {
        constructor(value) {
          this.value = value;
        }

        toString() {
          return `${this.value}`;
        }
      }
    }

    return window.CSSKeywordValue;
  })();

  await IRF.vdom.container;

  const numberOr = (value, defaultValue) => typeof value === "number" && !isNaN(value) ? value : defaultValue;

  class Customizing {
    constructor(config) {
      this.config = config;
    }

    registerMenuCommand() {
      GM.registerMenuCommand(this.config.menuCommand.name, () => this._handleMenuCommand(), { id: this.config.menuCommand.id });
    }

    _getFieldDefaultValues() {
      return Object.fromEntries(
        Object.values(this.config.fields)
          .map(({ key, defaultValue }) => [key, defaultValue])
      );
    }

    async _handleMenuCommand() {
      let panel;

      const initialValues = Object.assign(
        this._getFieldDefaultValues(),
        await GM.getValue(this.config.storageKey)
      );
      let formValues = structuredClone(initialValues);

      const copyButtonEl = VM.hm('button', {}, 'Copy all to clipboard');
      copyButtonEl.addEventListener('click', async () => {
        await navigator.clipboard.writeText(JSON.stringify(formValues));

        VM.showToast('Copied to clipboard', {
          theme: 'dark',
          duration: 1500
        });
      });

      const pasteButtonEl = VM.hm('button', {}, 'Paste from clipboard');
      pasteButtonEl.addEventListener('click', async () => {
        let clipboardData = await navigator.clipboard.readText();
        try {
          clipboardData = JSON.parse(clipboardData);
        } catch (error) {
          VM.showToast('Invalid data on clipboard', {
            theme: 'dark',
            duration: 1500
          });
          console.error("Could not parse JSON from clipboard", error);
          return;
        }

        formValues = structuredClone(clipboardData);
        await this.config.onApply(formValues);
        panel.setContent(renderPanelContents());

        VM.showToast('Pasted from clipboard', {
          theme: 'dark',
          duration: 1500
        });
      });

      const submitButtonEl = VM.hm('button', {}, 'Apply & Save');
      submitButtonEl.addEventListener('click', async () => {
        await GM.setValue(this.config.storageKey, formValues);
        await this.config.onApply(formValues);
        panel.hide();
      });

      const revertButtonEl = VM.hm('button', {}, 'Undo all changes');
      revertButtonEl.addEventListener('click', async () => {
        Object.assign(formValues, initialValues);
        await this.config.onApply(formValues);
        panel.setContent(renderPanelContents());
      });

      const cancelButtonEl = VM.hm('button', {}, 'Revert & Close');
      cancelButtonEl.addEventListener('click', async () => {
        await this.config.onApply(initialValues);
        panel.hide();
      });

      const renderPanelContents = () => {
        const fieldElements = Object.entries(this.config.fields).map(([fieldId, fieldConfig]) => {
          const getValue = (value) => formValues[fieldConfig.key];
          const setValue = (value) => {
            formValues[fieldConfig.key] = value;
            this.config.onApply(formValues);
          };

          const render = fieldConfig.render || (({ getValue, setValue, fieldId, fieldConfig }) => {
            const isCheckbox = fieldConfig.renderParams?.attrs?.type === 'checkbox';
            const inputEl = VM.hm('input', { id: fieldId, ... (fieldConfig.renderParams?.attrs ?? {}), [isCheckbox ? 'checked' : 'value']: getValue() });
            const labelEl = VM.hm('label', { for: fieldId, style: !isCheckbox ? 'display: block' : null }, fieldConfig.renderParams?.label);
            const subLabelEl = fieldConfig.renderParams?.subLabel != null && VM.hm('small', { class: 'field-group__sub-label' }, fieldConfig.renderParams.subLabel);

            const getInputValue = (inputEl) => inputEl.type === 'checkbox' ? inputEl.checked : inputEl.value;
            inputEl.addEventListener('change', () => {
              const inputValue = getInputValue(inputEl);
              const value = fieldConfig.renderParams?.parseInputValue?.(inputValue) ?? inputValue;
              setValue(value);
            });

            return VM.hm('div', { class: 'field-group' }, isCheckbox ? [inputEl, labelEl] : [labelEl, subLabelEl || null, inputEl]);
          });

          return render({ getValue, setValue, fieldId, fieldConfig });
        });

        return VM.hm('div', { class: 'edit-modal-content' }, [
          VM.hm('style', {}, Customizing.MODAL_CONTENT_STYLESHEET),
          VM.hm('h3', { style: 'margin: 0;' }, this.config.header),
          VM.hm('div', { className: 'button-row' }, [
            copyButtonEl,
            pasteButtonEl
          ]),
          ... fieldElements,
          VM.hm('div', { className: 'button-row' }, [
            submitButtonEl,
            revertButtonEl,
            cancelButtonEl
          ])
        ]);
      };

      panel = VM.getPanel({
        theme: 'dark',
        content: renderPanelContents()
      });
      panel.wrapper.classList.add('edit-modal-wrapper');
      panel.show();
    }

    async apply() {
      await GM.getValue(this.config.storageKey).then((config) => {
        this.config.onApply(config);
      });
    }
  }
  Customizing.MODAL_CONTENT_STYLESHEET = `
  * {
    box-sizing: border-box;
  }

  .edit-modal-wrapper {
    max-height: 100vh; /* fallback */
    max-height: 100dvh;
    overflow-y: auto;

    top: 50%;
    left: 50%;
    translate: -50% -50%;
  }

  .edit-modal-content {
    min-width: 300px;
    max-width: 425px;

    & .field-group {
      margin-block: 0.5em;

      & input:not([type="checkbox"]) {
        width: 100%;
      }

      & .field-group__sub-label {
        white-space: pre-wrap;
      }
    }

    & .button-row {
      margin-block: 0.5em;
      gap: 1em;
      justify-content: space-evenly;
      display: flex;
    }
  }
  `;

  async function migrateV1SteeringWheelImage() {
    const LEGACY_STEERING_WHEEL_IMAGE_SRC_STORE_ID = "imageSrc";
    const STEERING_WHEEL_STORE_ID = "steeringWheel";

    const legacyImageSrc = await GM.getValue(LEGACY_STEERING_WHEEL_IMAGE_SRC_STORE_ID, null);
    if (legacyImageSrc) {
      await GM.setValue(STEERING_WHEEL_STORE_ID, {
        imageSrc: legacyImageSrc
      });
      await GM.deleteValue(LEGACY_STEERING_WHEEL_IMAGE_SRC_STORE_ID);
    }
  }
  await migrateV1SteeringWheelImage();


  const FIELD_RENDERERS = {
    urlAndFileUpload({ getValue, setValue, fieldId, fieldConfig }) {
      const isDataUrl = () => getValue()?.startsWith('data:') ?? false;

      const urlInputEl = VM.hm('input', { id: fieldId, style: 'display: block', ... (fieldConfig.renderParams?.urlInputAttrs ?? {}) });
      urlInputEl.addEventListener('change', () => {
        setValue(urlInputEl.value);

        updateDom();
      });

      const errorTextEl = VM.hm('small', { style: 'color: red' });

      const fileInputEl = VM.hm('input', { id: fieldId, type: 'file', style: 'display: block' });
      fileInputEl.addEventListener('change', () => {
        const file = fileInputEl.files[0];
        if (!file) {
          return;
        }

        handleFileUpload(file);
      });

      const removeButtonEl = VM.hm('button', { style: 'white-space: nowrap' }, 'Remove');
      removeButtonEl.addEventListener('click', () => {
        if (
          fieldConfig.renderParams?.confirmPrompt &&
          isDataUrl() &&
          !confirm(fieldConfig.renderParams?.confirmPrompt.text)
        ) {
          return;
        }

        setValue('');

        updateDom();
      });

      const downloadButtonEl = VM.hm('button', { style: 'white-space: nowrap' }, 'Download');
      downloadButtonEl.addEventListener('click', async () => {
        const url = getValue();
        if (!url) {
          return;
        }

        const fileBlob = await fetch(url).then((res) => res.blob());
        const fileBlobUrl = URL.createObjectURL(fileBlob);
        window.open(fileBlobUrl, /* target */ "_blank");
        URL.revokeObjectURL(fileBlobUrl);
      });

      const updateDom = () => {
        urlInputEl.value = isDataUrl() ? '(uploaded file)' : getValue();
        urlInputEl.disabled = isDataUrl();

        removeButtonEl.style.display = getValue() ? '' : 'none';
        downloadButtonEl.style.display = getValue() ? '' : 'none';

        if (fieldConfig.renderParams.checkUrl) {
          Promise.resolve().then(() => fieldConfig.renderParams.checkUrl(getValue())).then((error) => {
            errorTextEl.style.display = error ? '' : 'none';
            errorTextEl.textContent = error;
          });
        }
      };
      updateDom();

      function handleFileUpload(file) {
        const fileReader = new FileReader();
        fileReader.onload = (event) => {
          setValue(event.target.result);

          updateDom();
        };
        fileReader.readAsDataURL(file);
      }

      const dropAreaEl = VM.hm('small', { className: 'drop-area' }, 'or drag and drop the file *here*');

      const containerEl = VM.hm('div', { class: 'field-group field-group--image-upload' }, [
        VM.hm('style', {}, `
        .field-group--image-upload {
          & > *:not(style) {
            display: block;
          }

          & .header {
            display: flex;
            gap: 0.25em;
            align-items: center;

            & .header__label {
              width: 100%;
            }
          }

          & .drop-area {
            padding: 0.5em;
            margin-block: 0.5em 0.25em;
            border: 3px dashed grey;
            user-select: none;
            transition: background-color 0.25s linear;

            &.drop-area__dropping {
              background-color: #007300;
            }
          }
        }
        `),
        VM.hm('div', { className: 'header' }, [
          VM.hm('label', { for: fieldId, className: 'header__label' }, fieldConfig.renderParams?.label),
          removeButtonEl,
          downloadButtonEl
        ]),
        errorTextEl,
        VM.hm('small', {}, 'URL:'),
        urlInputEl,
        VM.hm('small', {}, 'or upload a file'),
        fileInputEl,
        dropAreaEl
      ]);

      containerEl.addEventListener('paste', (event) => {
        const file = event.clipboardData.files[0];
        if (!file) {
          return;
        }

        handleFileUpload(file);
      });

      containerEl.addEventListener('dragover', (event) => {
        event.preventDefault();

        const containsValidData = event.dataTransfer.types.includes("Files");
        event.dataTransfer.dropEffect = containsValidData ? "move" : "none";
        dropAreaEl.classList.toggle('drop-area__dropping', containsValidData);
      });
      containerEl.addEventListener('dragleave', (event) => {
        dropAreaEl.classList.toggle('drop-area__dropping', false);
      });
      containerEl.addEventListener('drop', (event) => {
        event.preventDefault();
        dropAreaEl.classList.toggle('drop-area__dropping', false);

        const file = event.dataTransfer.files[0];
        if (!file) {
          return;
        }

        handleFileUpload(file);
      });

      return containerEl;
    },
    volumeSlider({ getValue, setValue, fieldId, fieldConfig }) {
      const numericInputEl = VM.hm('input', {
        id: fieldId,
        type: 'number',
        className: 'field-group--volume-slider__numeric-input',
        min: 0,
        max: 100
      });
      const rangeInputEl = VM.hm('input', {
        id: fieldId,
        type: 'range',
        className: 'field-group--volume-slider__range-input',
        min: 0,
        max: 100,
        step: 1
      });

      function updateDom() {
        const value = Math.floor(getValue() * 100);
        numericInputEl.value = value;
        rangeInputEl.value = value;
      }
      updateDom();

      function handleInputInput(event) {
        const numericValue = parseInt(event.target.value, 10);
        if (isNaN(numericValue)) {
          return;
        }

        setValue(numericValue / 100);
        updateDom();
      }

      numericInputEl.addEventListener('input', handleInputInput);
      rangeInputEl.addEventListener('input', handleInputInput);

      return VM.hm('div', { class: 'field-group field-group--volume-slider' }, [
        VM.hm('style', {}, `
        .field-group--volume-slider {
          & .field-group--volume-slider__inputs-container {
            display: flex;
          }

          & .field-group--volume-slider__numeric-input {
            width: 6ch !important;
          }
        }
        `),
        VM.hm('label', { for: fieldId }, fieldConfig.renderParams?.label),
        VM.hm('div', { className: 'field-group--volume-slider__inputs-container' }, [
          rangeInputEl,
          numericInputEl
        ])
      ]);
    }
  }

  const FIELDS = {
    hide: {
      key: 'hide',
      defaultValue: false,
      renderParams: {
        label: 'Hide',
        attrs: {
          type: 'checkbox'
        }
      }
    },
    interactable: {
      key: 'interactable',
      defaultValue: true,
      renderParams: {
        label: 'interactable',
        attrs: {
          type: 'checkbox'
        }
      }
    },
    volume: {
      key: 'volume',
      defaultValue: 0.5,
      renderParams: {
        label: 'Volume'
      },
      render: FIELD_RENDERERS.volumeSlider
    },
    imageSrc: {
      key: 'imageSrc',
      defaultValue: '',
      renderParams: {
        label: 'Image',
        confirmPrompt: {
          text: [
            `Are you sure you want to remove this image?`,
            `You may want to make a backup of it for later: press "Cancel"/"No" on this prompt and then "Download" to get a copy of the current image.`
          ].join("\n\n")
        },
        urlInputAttrs: {
          placeholder: '(default)'
        },
        checkUrl: async (url) => {
          const isDiscordCdnUrl = () => {
            if (!url) {
              return false;
            }

            try {
              const urlUrl = new URL(url);
              return urlUrl.hostname === "cdn.discordapp.com";
            } catch (_) {
              return false;
            }
          };

          if (isDiscordCdnUrl(url)) {
            return 'Avoid using Discord CDN URLs as these eventually expire! Instead, try directly uploading the image below.';
          }
        }
      },
      render: FIELD_RENDERERS.urlAndFileUpload
    },
    imageScale: {
      key: 'imageScale',
      defaultValue: 1,
      renderParams: {
        label: 'Image Scale',
        attrs: {
          type: 'number',
          step: 0.1
        },
        parseInputValue: (value) => numberOr(parseFloat(value), 1)
      }
    },
    imageOffsetX: {
      key: 'offsetX',
      defaultValue: 0,
      renderParams: {
        label: 'Image Offset X',
        attrs: {
          type: 'number',
        },
        parseInputValue: (value) => numberOr(parseFloat(value), 1)
      }
    },
    imageOffsetY: {
      key: 'offsetY',
      defaultValue: 0,
      renderParams: {
        label: 'Image Offset Y',
        attrs: {
          type: 'number',
        },
        parseInputValue: (value) => numberOr(parseFloat(value), 1)
      }
    },
    imageZIndex: {
      key: 'imageZIndex',
      defaultValue: null,
      renderParams: {
        label: 'Image Z-Index',
        subLabel: 'Higher values make this render above other things, while lower values make this render behind other things. Leave empty for default.',
        attrs: {
          type: 'number',
          placeholder: '(default)'
        },
        parseInputValue: (value) => numberOr(parseFloat(value), null)
      }
    }
  };

  const debugSettings = Object.assign({
    interactable : false
  }, await GM.getValue("DEBUG", {}));


  /**
   * Things this bad polyfill doesn't consider:
   * - Rule priority (it always assumes the last rule is the better one)
   * - Attribute value checks (it only supports checking the presence of an attribute)
   * - Lots of other CSS features I can't even think of
   *
   * @param {Element[]} elements
   * @returns {Map<Element, { get(propName: string) => CSSUnitValue | CSSKeywordValue }}
   */
  function bulkComputedStyleMapBadPolyfill(elements) {
    if (SUPPORTS_ELEMENT_COMPUTED_STYLE_MAP && !debugSettings.forceComputedStyleMapPolyfill) {
      return new Map(elements.map((element) => [element, element.computedStyleMap()]));
    }

    function scanRules(styleSheetOrRule, callback) {
      for (const rule of (styleSheetOrRule.rules ?? styleSheetOrRule.cssRules ?? [])) {
        if (rule.cssRules?.length > 0) {
          scanRules(rule.cssRules, callback);
          continue;
        }

        callback(rule);
      }
    }

    const propsAndValuesPerElement = new Map();

    const CSS_SELECTOR_PIECE_PARTS_SPLIT_REGEXP = /(?=[#.\[])/g;

    for (const styleSheet of document.styleSheets) {
      if (styleSheet.href != null && new URL(styleSheet.href).hostname !== window.location.hostname) {
        continue;
      }

      scanRules(styleSheet, (rule) => {
        if (!(rule instanceof CSSStyleRule)) {
          return;
        }

        // The little CSS engine that could...
        const ruleSelectors = rule.selectorText.split(',').map((rawSelector) => rawSelector.trim());

        const ruleSelectorLastPieces = ruleSelectors
          .map((selector) => selector.split(' ').at(-1) ?? '')
          .filter((selectorLastPiece) => {
            if (selectorLastPiece.includes('::')) {
              // Pseudo-elements, which we don't care about
              return false;
            }

            return true;
          });

        const ruleSelectorLastPieceParts = ruleSelectorLastPieces.map((lastPiece) => lastPiece.split(CSS_SELECTOR_PIECE_PARTS_SPLIT_REGEXP));

        const doesRuleSeemToMatchElement = (element) => ruleSelectorLastPieceParts.some((splitPiecePart) => splitPiecePart.every((piecePart) => {
          switch (piecePart[0]) {
            case '#': {
              return element.id === piecePart.slice(1);
            }
            case '.': {
              return element.classList.contains(piecePart.slice(1));
            }
            case '[': {
              return element.hasAttribute(piecePart.slice(1, -1));
            }
            default: {
              return element.tagName.toLowerCase() === piecePart.toLowerCase();
            }
          }
        }));


        for (const element of elements) {
          if (!doesRuleSeemToMatchElement(element)) {
            continue;
          }

          const styleMapEntries = {};
          for (const propName of rule.style) {
            const rawValue = rule.style[propName];
            if (rawValue == null) {
              continue;
            }

            styleMapEntries[propName] = cssValueFromRawValue(rawValue);
          }

          let propsAndValuesForThisElement = propsAndValuesPerElement.get(element) ?? {};
          propsAndValuesForThisElement = {
            ... propsAndValuesForThisElement,
            ... styleMapEntries
          };

          propsAndValuesPerElement.set(element, propsAndValuesForThisElement);
        }
      });
    }

    return new Map(
      Array.from(propsAndValuesPerElement.entries()).map(([element, propsAndValues]) => [
        element,
        {
          _propsAndValues: propsAndValues,
          get(propName) {
            const values = propsAndValues[propName];
            if (Array.isArray(values) && values.length === 1) {
              return values[0];
            }

            // ¯\_(ツ)_/¯ I don't know how this API works, and I don't feel like checking
            return values;
          }
        }
      ])
    );
  }

  const computedStyleMapBadPolyfill = (element) => bulkComputedStyleMapBadPolyfill([element]).get(element);

  function getStyleMapFirstProp(styleMap, propsToCheck) {
    for (const propName of propsToCheck) {
      const propValue = styleMap.get(propName);
      if (propValue && propValue.value !== 'auto') {
        return { name: propName, value: propValue };
      }
    }
  }

  const wheelContainerEl = await IRF.dom.wheel;
  const wheelImageEl = wheelContainerEl.querySelector('img.wheel');

  const freshenerContainerEl = await IRF.dom.freshener;
  const freshenerImageEl = freshenerContainerEl.querySelector('img.freshener-img');
  const freshenerImageParentEl = freshenerImageEl.parentElement;

  const radioContainerEl = await IRF.dom.radio;
  const coffeeCupImageEl = radioContainerEl.querySelector('img.coffee');
  const odometerContainerEl = await IRF.dom.odometer;

  const initialComputedStyleMapResults = bulkComputedStyleMapBadPolyfill([
    wheelContainerEl, wheelImageEl,
    freshenerImageEl, freshenerImageParentEl,
    radioContainerEl, coffeeCupImageEl,
    odometerContainerEl,
  ]);

  {
    const containerEl = wheelContainerEl;
    const imageEl = wheelImageEl;

    const defaultImageSrc = imageEl.src;

    const initialContainerStyle = initialComputedStyleMapResults.get(containerEl);
    const initialContainerStyleTopOrBottom = getStyleMapFirstProp(initialContainerStyle, ['top', 'bottom']) ?? { name: 'top', value: new window.CSSUnitValue(0, 'px') };
    const initialContainerStyleLeftOrRight = getStyleMapFirstProp(initialContainerStyle, ['left', 'right']) ?? { name: 'left', value: new window.CSSUnitValue(0, 'px') };

    const initialImageStyle = initialComputedStyleMapResults.get(imageEl);
    const initialImageStyleTopOrBottom = getStyleMapFirstProp(initialImageStyle, ['top', 'bottom']) ?? { name: 'top', value: new window.CSSUnitValue(0, 'px') };
    const initialImageStyleLeftOrRight = getStyleMapFirstProp(initialImageStyle, ['left', 'right']) ?? { name: 'left', value: new window.CSSUnitValue(0, 'px') };

    const steeringWheelCustomizing = new Customizing({
      storageKey: 'steeringWheel',
      menuCommand: {
        id: 'customize-internet-roadtrip-steering-wheel',
        name: 'Customize steering wheel'
      },
      header: 'Customize steering wheel',
      fields: {
        'steering-wheel-hide': {
          ... FIELDS.hide,
          renderParams: {
            ... FIELDS.hide.renderParams,
            label: 'Hide Image',
          }
        },
        'steering-wheel-image': FIELDS.imageSrc,
        'steering-wheel-container-scale': {
          ... FIELDS.imageScale,
          key: 'containerScale',
          renderParams: {
            ... FIELDS.imageScale.renderParams,
            label: 'Container Scale',
          }
        },
        'steering-wheel-container-offset-x': {
          ... FIELDS.imageOffsetX,
          key: 'containerOffsetX',
          renderParams: {
            ... FIELDS.imageOffsetX.renderParams,
            label: 'Container Offset X',
          }
        },
        'steering-wheel-container-offset-y': {
          ... FIELDS.imageOffsetY,
          key: 'containerOffsetY',
          renderParams: {
            ... FIELDS.imageOffsetY.renderParams,
            label: 'Container Offset Y',
          }
        },
        'steering-wheel-image-scale': FIELDS.imageScale,
        'steering-wheel-offset-x': FIELDS.imageOffsetX,
        'steering-wheel-offset-y': FIELDS.imageOffsetY
      },
      onApply(config) {
        const {
          hide = false,
          imageSrc = null,
          containerScale = 1,
          containerOffsetX = 0,
          containerOffsetY = 0,
          imageScale = 1,
          offsetX = 0,
          offsetY = 0
        } = config || {};
        imageEl.style.display = hide ? 'none' : '';
        imageEl.src = imageSrc || defaultImageSrc;

        imageEl.style.scale = imageScale;
        imageEl.style[initialImageStyleLeftOrRight.name] = `calc(${initialImageStyleLeftOrRight.value.toString()} + ${(initialImageStyleLeftOrRight.name === 'left' ? 1 : -1) * offsetX}px)`;
        imageEl.style[initialImageStyleTopOrBottom.name] = `calc(${initialImageStyleTopOrBottom.value.toString()} + ${(initialImageStyleTopOrBottom.name === 'top' ? 1 : -1) * offsetY}px)`;

        containerEl.style.scale = containerScale;
        containerEl.style[initialContainerStyleLeftOrRight.name] = `calc(${initialContainerStyleLeftOrRight.value.toString()} + ${(initialContainerStyleLeftOrRight.name === 'left' ? 1 : -1) * containerOffsetX}px)`;
        containerEl.style[initialContainerStyleTopOrBottom.name] = `calc(${initialContainerStyleTopOrBottom.value.toString()} + ${(initialContainerStyleTopOrBottom.name === 'top' ? 1 : -1) * containerOffsetY}px)`;
      }
    });
    steeringWheelCustomizing.registerMenuCommand();
    steeringWheelCustomizing.apply();
  }

  {
    const containerEl = freshenerContainerEl;
    const imageEl = freshenerImageEl;
    const imageParentEl = freshenerImageParentEl;

    const defaultImageSrc = imageEl.src;

    const initialImageParentStyle = initialComputedStyleMapResults.get(imageParentEl);
    const initialImageParentStyleTopOrBottom = getStyleMapFirstProp(initialImageParentStyle, ['top', 'bottom']) ?? { name: 'top', value: new window.CSSUnitValue(0, 'px') };
    const initialImageParentStyleLeftOrRight = getStyleMapFirstProp(initialImageParentStyle, ['left', 'right']) ?? { name: 'left', value: new window.CSSUnitValue(0, 'px') };

    const airFreshenerCustomizing = new Customizing({
      storageKey: 'airFreshener',
      menuCommand: {
        id: 'customize-internet-roadtrip-air-freshener',
        name: 'Customize air freshener'
      },
      header: 'Customize air freshener',
      fields: {
        'air-freshener-hide': FIELDS.hide,
        'air-freshener-interactable': FIELDS.interactable,
        'air-freshener-image': FIELDS.imageSrc,
        'air-freshener-image-scale': FIELDS.imageScale,
        'air-freshener-offset-x': FIELDS.imageOffsetX,
        'air-freshener-offset-y': FIELDS.imageOffsetY
      },
      onApply(config) {
        const {
          hide = false,
          interactable = true,
          imageSrc = null,
          imageScale = 1,
          offsetX = 0,
          offsetY = 0
        } = config || {};
        containerEl.style.display = hide ? 'none' : '';
        imageParentEl.style.pointerEvents = interactable ? '' : 'none';

        imageEl.src = imageSrc || defaultImageSrc;

        imageParentEl.style.scale = imageScale;
        imageParentEl.style[initialImageParentStyleLeftOrRight.name] = `calc(${initialImageParentStyleLeftOrRight.value.toString()} + ${offsetX}px)`;
        imageParentEl.style[initialImageParentStyleTopOrBottom.name] = `calc(${initialImageParentStyleTopOrBottom.value.toString()} + ${offsetY}px)`;
      }
    });
    airFreshenerCustomizing.registerMenuCommand();

    IRF.vdom.freshener.then((freshenerVDOM) => {
      freshenerVDOM.state.initPhysics = new Proxy(freshenerVDOM.methods.initPhysics, {
        apply(ogInitPhysics, thisArg, args) {
          const result = ogInitPhysics.apply(thisArg, args);

          airFreshenerCustomizing.apply();

          return result;
        }
      });

      if (freshenerVDOM.data.engine) {
        airFreshenerCustomizing.apply();
      }
    });
  }

  {
    const radioVDOM = await IRF.vdom.radio;
    const defaultCoffeeCupOnHoverSound = radioVDOM.state.coffeeSound;
    const defaultCoffeeCupOnHoverSoundVolume = defaultCoffeeCupOnHoverSound.volume();

    const imageEl = coffeeCupImageEl;

    const defaultImageSrc = imageEl.src;

    const initialImageStyle = initialComputedStyleMapResults.get(imageEl);
    const initialImageStyleTopOrBottom = getStyleMapFirstProp(initialImageStyle, ['top', 'bottom']) ?? { name: 'top', value: new window.CSSUnitValue(0, 'px') };
    const initialImageStyleLeftOrRight = getStyleMapFirstProp(initialImageStyle, ['left', 'right']) ?? { name: 'left', value: new window.CSSUnitValue(0, 'px') };

    const initialRadioContainerStyle = initialComputedStyleMapResults.get(radioContainerEl);
    const initialRadioContainerZIndex = initialRadioContainerStyle.get('z-index');

    const initialOdometerContainerStyle = initialComputedStyleMapResults.get(odometerContainerEl);
    const initialOdometerContainerZIndex = initialOdometerContainerStyle.get('z-index');

    let lastCoffeeCupOnHoverSoundSrc = null;

    const coffeeCupCustomizing = new Customizing({
      storageKey: 'coffeeCup',
      menuCommand: {
        id: 'customize-internet-roadtrip-coffee-cup',
        name: 'Customize coffee cup'
      },
      header: 'Customize coffee cup',
      fields: {
        'coffee-cup-hide': FIELDS.hide,
        'coffee-cup-interactable': FIELDS.interactable,
        'coffee-cup-on-hover-sound': {
          key: 'hoverSoundSrc',
          defaultValue: '',
          renderParams: {
            label: 'Sound',
            confirmPrompt: {
              text: [
                `Are you sure you want to remove this sound?`,
                `You may want to make a backup of it for later: press "Cancel"/"No" on this prompt and then "Download" to get a copy of the current sound effect.`
              ].join("\n\n")
            },
            urlInputAttrs: {
              placeholder: '(default)'
            },
            checkUrl: async (url) => {
              const isDiscordCdnUrl = () => {
                if (!url) {
                  return false;
                }

                try {
                  const urlUrl = new URL(url);
                  return urlUrl.hostname === "cdn.discordapp.com";
                } catch (_) {
                  return false;
                }
              };

              if (isDiscordCdnUrl(url)) {
                return 'Avoid using Discord CDN URLs as these eventually expire! Instead, try directly uploading the sound below.';
              }
            }
          },
          render: FIELD_RENDERERS.urlAndFileUpload
        },
        'coffee-cup-on-hover-sound-volume': {
          ... FIELDS.volume,
          key: 'hoverSoundVolume',
          defaultValue: defaultCoffeeCupOnHoverSoundVolume,
          renderParams: {
            ... FIELDS.volume.renderParams,
            label: 'Sound Volume'
          }
        },
        'coffee-cup-image': FIELDS.imageSrc,
        'coffee-cup-image-scale': FIELDS.imageScale,
        'coffee-cup-offset-x': FIELDS.imageOffsetX,
        'coffee-cup-offset-y': FIELDS.imageOffsetY,
        'coffee-cup-image-z-index': FIELDS.imageZIndex,
        'odometer-z-index': {
          ... FIELDS.imageZIndex,
          key: 'odometerZIndex',
          renderParams: {
            ... FIELDS.imageZIndex.renderParams,
            label: 'Odometer Z-Index',
            subLabel: [
              'Same as image z-index, but for the odometer. Play around with lower values to make the odometer go behind the coffee cup.',
              initialRadioContainerZIndex && `Radio z-index: ${initialRadioContainerZIndex?.value}`,
              initialOdometerContainerZIndex && `Odometer default z-index: ${initialOdometerContainerZIndex?.value}`
            ].filter((str) => !!str).join('\n'),
          }
        }
      },
      onApply(config) {
        const {
          hide = false,
          interactable = true,
          hoverSoundSrc = null,
          hoverSoundVolume = defaultCoffeeCupOnHoverSoundVolume,
          imageSrc = null,
          imageScale = 1,
          imageZIndex = null,
          offsetX = 0,
          offsetY = 0,
          odometerZIndex = null
        } = config || {};
        imageEl.style.display = hide ? 'none' : '';
        imageEl.style.pointerEvents = interactable ? '' : 'none';

        if (lastCoffeeCupOnHoverSoundSrc !== hoverSoundSrc) {
          radioVDOM.state.coffeeSound = hoverSoundSrc
            ? new Howl({
              src: [hoverSoundSrc],
              volume: defaultCoffeeCupOnHoverSound.volume()
            })
            : defaultCoffeeCupOnHoverSound;
          lastCoffeeCupOnHoverSoundSrc = hoverSoundSrc;
        }
        radioVDOM.state.coffeeSound.volume(hoverSoundVolume);

        imageEl.src = imageSrc || defaultImageSrc;

        imageEl.style.zIndex = imageZIndex || '';

        imageEl.style.zoom = imageScale;
        imageEl.style[initialImageStyleLeftOrRight.name] = `calc(${initialImageStyleLeftOrRight.value.toString()} + ${(initialImageStyleLeftOrRight.name === 'left' ? 1 : -1) * offsetX}px)`;
        imageEl.style[initialImageStyleTopOrBottom.name] = `calc(${initialImageStyleTopOrBottom.value.toString()} + ${(initialImageStyleTopOrBottom.name === 'top' ? 1 : -1) * offsetY}px)`;

        odometerContainerEl.style.zIndex = odometerZIndex || '';
      }
    });
    coffeeCupCustomizing.registerMenuCommand();
    coffeeCupCustomizing.apply();
  }
})();