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