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.3.2
// @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],npm/[email protected]
// ==/UserScript==

/* globals IRF, VM */

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

    async _handleMenuCommand() {
      let panel;

      const initialValues = await GM.getValue(this.config.storageKey) || {};

      const fieldElements = Object.fromEntries(
        Object.entries(this.config.fields).map(([fieldId, fieldConfig]) => {
          const isCheckbox = fieldConfig.attrs?.type === 'checkbox';
          const inputEl = VM.hm('input', { id: fieldId, ... (fieldConfig.attrs ?? {}), [isCheckbox ? 'checked' : 'value']: initialValues[fieldConfig.key] || fieldConfig.defaultValue });
          const labelEl = VM.hm('label', { for: fieldId, style: !isCheckbox ? 'display: block' : null }, fieldConfig.label);

          inputEl.addEventListener('change', () => this.config.onApply(aggregateSaveConfig()));

          return [fieldId, {
            labelEl,
            inputEl
          }];
        })
      );

      const getInputValue = (inputEl) => inputEl.type === 'checkbox' ? inputEl.checked : inputEl.value;

      const aggregateSaveConfig = () => Object.fromEntries(
        Object.entries(fieldElements).map(([fieldId, { inputEl }]) => {
          const fieldConfig = this.config.fields[fieldId];
          const inputValue = getInputValue(inputEl);
          return [fieldConfig.key, fieldConfig.parseInputValue?.(inputValue) || inputValue];
        })
      );

      const submitBtnEl = VM.hm('button', {}, 'Apply & Save');
      const cancelBtnEl = VM.hm('button', {}, 'Revert & Close');

      submitBtnEl.addEventListener('click', async () => {
        await GM.setValue(this.config.storageKey, aggregateSaveConfig());
        await this.config.onApply(aggregateSaveConfig());
        panel.hide();
      });

      cancelBtnEl.addEventListener('click', async () => {
        await this.config.onApply(initialValues);
        panel.hide();
      });

      panel = VM.getPanel({
        theme: 'dark',
        content: VM.hm('div', {}, [
          VM.hm('h3', { style: 'margin: 0;' }, this.config.header),
          ... Object.values(fieldElements)
            .map(({ labelEl, inputEl }) => VM.hm('div', {}, inputEl.type === 'checkbox' ? [inputEl, labelEl] : [labelEl, inputEl])),
          VM.hm('div', {}, [
            submitBtnEl,
            cancelBtnEl
          ])
        ])
      });
      Object.assign(panel.wrapper.style, {
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%)'
      });
      panel.show();
    }

    apply() {
      GM.getValue(this.config.storageKey).then((config) => {
        this.config.onApply(config);
      });
    }
  }

  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 HIDE_FIELD = {
    key: 'hide',
    label: 'Hide',
    attrs: {
      type: 'checkbox'
    },
    defaultValue: false
  };
  const IMAGE_URL_FIELD = {
    key: 'imageSrc',
    label: 'Image URL',
    attrs: {
      type: 'text',
      placeholder: '(default)'
    },
    defaultValue: '',
    parseInputValue: (value) => value || null
  };
  const IMAGE_SCALE_FIELD = {
    key: 'imageScale',
    label: 'Image Scale',
    attrs: {
      type: 'number',
      step: 0.1
    },
    defaultValue: 1,
    parseInputValue: (value) => numberOr(parseFloat(value), 1)
  };
  const IMAGE_OFFSET_X_FIELD = {
    key: 'offsetX',
    label: 'Image Offset X',
    attrs: {
      type: 'number',
    },
    defaultValue: 0,
    parseInputValue: (value) => numberOr(parseFloat(value), 1)
  };
  const IMAGE_OFFSET_Y_FIELD = {
    key: 'offsetY',
    label: 'Image Offset Y',
    attrs: {
      type: 'number',
    },
    defaultValue: 0,
    parseInputValue: (value) => numberOr(parseFloat(value), 1)
  };

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

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

    if (!window.CSSUnitValue) {
      class CSSUnitValue {
        constructor(value, unit) {
          this.value = value;
          this.unit = unit;
        }

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

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

    return window.CSSUnitValue;
  })();

  /**
   * 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) => window.CSSUnitValue }}
   */
  function bulkComputedStyleMapBadPolyfill(elements) {
    if (elements[0].computedStyleMap && !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) {
            styleMapEntries[propName] = window.CSSUnitValue.fromCssValue(rule.style[propName]);
          }

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

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

    return new Map(
      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 initialComputedStyleMapResults = bulkComputedStyleMapBadPolyfill([
    wheelContainerEl, wheelImageEl,
    freshenerImageEl, freshenerImageParentEl,
    coffeeCupImageEl,
  ]);

  {
    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': {
          ... HIDE_FIELD,
          label: 'Hide Image',
        },
        'steering-wheel-image-url': IMAGE_URL_FIELD,
        'steering-wheel-container-scale': {
          ... IMAGE_SCALE_FIELD,
          key: 'containerScale',
          label: 'Container Scale'
        },
        'steering-wheel-container-offset-x': {
          ... IMAGE_OFFSET_X_FIELD,
          key: 'containerOffsetX',
          label: 'Container Offset X'
        },
        'steering-wheel-container-offset-y': {
          ... IMAGE_OFFSET_Y_FIELD,
          key: 'containerOffsetY',
          label: 'Container Offset Y'
        },
        'steering-wheel-image-scale': IMAGE_SCALE_FIELD,
        'steering-wheel-offset-x': IMAGE_OFFSET_X_FIELD,
        'steering-wheel-offset-y': IMAGE_OFFSET_Y_FIELD
      },
      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': HIDE_FIELD,
        'air-freshener-image-url': IMAGE_URL_FIELD,
        'air-freshener-image-scale': IMAGE_SCALE_FIELD,
        'air-freshener-offset-x': IMAGE_OFFSET_X_FIELD,
        'air-freshener-offset-y': IMAGE_OFFSET_Y_FIELD
      },
      onApply(config) {
        const { hide = false, imageSrc = null, imageScale = 1, offsetX = 0, offsetY = 0 } = config || {};
        containerEl.style.display = hide ? '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();
    airFreshenerCustomizing.apply();
  }

  {
    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 coffeeCupCustomizing = new Customizing({
      storageKey: 'coffeeCup',
      menuCommand: {
        id: 'customize-internet-roadtrip-coffee-cup',
        name: 'Customize coffee cup'
      },
      header: 'Customize coffee cup',
      fields: {
        'coffee-cup-hide': HIDE_FIELD,
        'coffee-cup-image-url': IMAGE_URL_FIELD,
        'coffee-cup-image-scale': IMAGE_SCALE_FIELD,
        'coffee-cup-offset-x': IMAGE_OFFSET_X_FIELD,
        'coffee-cup-offset-y': IMAGE_OFFSET_Y_FIELD
      },
      onApply(config) {
        const { hide = false, imageSrc = null, imageScale = 1, offsetX = 0, offsetY = 0 } = config || {};
        imageEl.style.display = hide ? 'none' : '';
        imageEl.src = imageSrc || defaultImageSrc;
        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)`;
      }
    });
    coffeeCupCustomizing.registerMenuCommand();
    coffeeCupCustomizing.apply();
  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址