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