Skip to content

Instantly share code, notes, and snippets.

@raingart
Last active August 13, 2025 08:27
Show Gist options
  • Select an option

  • Save raingart/23f06b1fb9cf875bc3c19621af43fb88 to your computer and use it in GitHub Desktop.

Select an option

Save raingart/23f06b1fb9cf875bc3c19621af43fb88 to your computer and use it in GitHub Desktop.
(() => {
'use strict';
// Polyfill for Object.fromEntries for older environments
const fromEntries = Object.fromEntries || (iterable => [...iterable].reduce((obj, [key, val]) => ((obj[key] = val), obj), {}));
// Utility to create DOM elements with options
const makeElem = (type, { classes, dataset, ...opts } = {}) => {
const node = Object.assign(document.createElement(type), opts);
if (classes) node.classList.add(...classes);
if (dataset) Object.assign(node.dataset, dataset);
return node;
};
/**
* Main class for creating and managing the configuration UI.
* Uses native private class fields (#) for true encapsulation.
*/
class GM_Config_UI {
// Private fields declaration
#dialog = null;
#activeElement = null;
#classNames = {}; // To hold prefixed class names
static stylesInjected = {};
constructor({ fields, storage = 'cfg', prefix = 'gm-config', title = 'Settings' }) {
this.settingsArray = Object.entries(fields).map(([key, props]) => ({ key, ...props }));
this.storage = storage;
this.prefix = prefix;
this.title = title;
// Callbacks
this.onsave = null;
this.oncancel = null;
this.onchange = null;
const classMap = ['dialog', 'header', 'title', 'body', 'footer', 'ModalCloseBtn'];
this.#classNames = fromEntries(classMap.map(key => [key, `${this.prefix}-${key}`]));
}
// Public API Methods
load() {
const defaults = fromEntries(this.settingsArray.map(({ key, default: def }) => [key, def]));
try {
const storedValue = window.GM_getValue ? GM_getValue(this.storage) : localStorage.getItem(this.storage);
const storedParsed = storedValue ? JSON.parse(storedValue) : {};
return { ...defaults, ...storedParsed };
} catch (e) {
console.error(`[${this.prefix}] Error loading config:`, e);
return defaults;
}
}
save(cfg) {
const data = JSON.stringify(cfg);
window.GM_setValue
? GM_setValue(this.storage, data)
: localStorage.setItem(this.storage, data);
}
setup() {
if (document.querySelector(`.${this.#classNames.dialog}`)) return;
this.#activeElement = document.activeElement;
this.#injectStyle();
this.#dialog = this.#buildUI();
this.#attachEventListeners();
document.body.append(this.#dialog);
this.#dialog.showModal();
this.#dialog.querySelector('input, select, textarea')?.focus();
}
close() {
this.#dialog?.close();
}
#cleanup() {
this.#dialog?.remove();
this.#dialog = null;
this.#activeElement?.focus();
this.#activeElement = null;
}
#injectStyle() {
if (GM_Config_UI.stylesInjected[this.prefix]) return;
const css = `
/* CSS Isolation & Reset */
dialog.${this.#classNames.dialog} { all: revert; }
.${this.#classNames.dialog} *, .${this.#classNames.dialog} *::before, .${this.#classNames.dialog} *::after {
box-sizing: border-box;
}
dialog.${this.#classNames.dialog} * {
font: inherit;
font-size: initial;
color: inherit;
background: transparent;
border: none;
outline: none;
margin: 0;
padding: 0;
text-transform: none;
letter-spacing: normal;
}
dialog.${this.#classNames.dialog} {
/* CSS Variables */
--bg-color: #807e7eeb;
--text-color: #f1f1f1;
--text-color-input: #ffffff;
--text-shadow: 0 0 3px #333;
--backdrop-bg: rgba(0, 0, 0, .6);
--border-color: #4f545c;
--border-color-button: #99aab5;
--input-bg: #23272a;
--accent-color: #7289da;
--accent-color: orange;
--accent-color-hover: darkorange;
--selection-bg: var(--accent-color);
--selection-color: #111;
--danger-color: #dc3545;
--radius: 1em;
--spacing-md: .8em;
--spacing-lg: 20px;
--animation-duration: 150ms;
backdrop-filter: blur(3px);
}
/* Base Dialog Styles */
dialog.${this.#classNames.dialog} {
background: var(--bg-color);
color: var(--text-color);
border: 0;
border-radius: var(--radius);
box-shadow: 0 0 10px var(--backdrop-bg);
padding: 0;
max-width: 90vw;
min-width: 400px
width: fit-content;
font-family: sans-serif;
font-size: 1.1em;
}
dialog.${this.#classNames.dialog}[open] {
display: flex;
flex-direction: column;
animation: gm-config-fade-in var(--animation-duration) ease-out forwards;
}
dialog.${this.#classNames.dialog}::backdrop {
background: var(--backdrop-bg, rgba(0, 0, 0, .4));
animation: gm-config-fade-in-backdrop var(--animation-duration) ease-out forwards;
}
dialog.${this.#classNames.dialog} *::selection {
color: var(--selection-color);
background-color: var(--selection-bg);
text-shadow: none;
}
/* Layout: Header, Body, Footer */
dialog.${this.#classNames.dialog} .${this.#classNames.header},
dialog.${this.#classNames.dialog} .${this.#classNames.footer} {
flex-shrink: 0;
background-color: var(--header-footer-bg);
margin: 0 calc(var(--spacing-lg) / 2);
padding: var(--spacing-md) calc(var(--spacing-lg) / 2);
}
dialog.${this.#classNames.dialog} .${this.#classNames.header} {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
padding-top: calc(var(--spacing-md) / 2);
padding-bottom: calc(var(--spacing-md) / 2);
}
dialog.${this.#classNames.dialog} .${this.#classNames.title} {
font-size: 1em;
font-weight: 600;
margin: 0;
text-transform: uppercase;
letter-spacing: 1px;
text-shadow: var(--text-shadow);
border: 0;
}
dialog.${this.#classNames.dialog} .${this.#classNames.ModalCloseBtn} {
background: none;
border: 0;
color: var(--text-color);
font-size: 1.8em;
line-height: 1;
padding: 0 .2em;
cursor: pointer;
opacity: .7;
transition: opacity var(--animation-duration), transform var(--animation-duration);
}
.${this.#classNames.ModalCloseBtn}:hover {
opacity: 1;
outline: 1px solid var(--border-color-button);
}
dialog.${this.#classNames.dialog} .${this.#classNames.body} {
padding: var(--spacing-lg);
display: grid;
align-items: center;
grid-template-columns: max-content minmax(auto, 1fr);
gap: var(--spacing-md);
max-height: 80vh;
overflow-y: auto;
}
dialog.${this.#classNames.dialog} .${this.#classNames.footer} {
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--border-color);
}
/* Form Elements */
.${this.#classNames.dialog} button,
.${this.#classNames.dialog} [type=submit],
.${this.#classNames.dialog} [type=reset],
.${this.#classNames.dialog} input[type=button],
.${this.#classNames.dialog} input[type=checkbox],
.${this.#classNames.dialog} input[type=color],
.${this.#classNames.dialog} input[type=radio],
.${this.#classNames.dialog} input[type=range],
.${this.#classNames.dialog} label,
.${this.#classNames.dialog} select {
cursor: pointer;
}
dialog.${this.#classNames.dialog} label {
justify-self: end;
font-weight: 500;
text-shadow: var(--text-shadow);
}
dialog.${this.#classNames.dialog} input,
dialog.${this.#classNames.dialog} select,
dialog.${this.#classNames.dialog} textarea {
background-color: var(--input-bg);
color: var(--text-color-input);
border: 1px solid var(--border-color);
padding: 6px 8px;
transition: border-color var(--animation-duration), box-shadow var(--animation-duration);
}
dialog.${this.#classNames.dialog} select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E%3Cpath d='M4.646 6.646a.5.5 0 0 1 .708 0L8 9.293l2.646-2.647a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right .5rem center;
background-size: 1.3em;
}
/* Simple styled checkbox toggle */
.${this.#classNames.dialog} input[type="checkbox"] {
--size: 3em;
position: relative;
appearance: none;
-webkit-appearance: none;
width: var(--size);
height: calc(var(--size) / 2);
border-radius: 1em;
transition: background-color var(--animation-duration);
}
.${this.#classNames.dialog} input[type="checkbox"]::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: calc(var(--size) / 2);
height: calc(var(--size) / 2);
background-color: var(--text-color-input);
border-radius: 50%;
transition: left var(--animation-duration);
}
.${this.#classNames.dialog} input[type="checkbox"]:checked {
background-color: var(--accent-color);
border-color: var(--accent-color);
}
.${this.#classNames.dialog} input[type="checkbox"]:checked::after {
left: calc(var(--size) / 2);
}
.${this.#classNames.dialog} input[type="checkbox"]:hover {
border-color: var(--accent-color);
}
/* Element States: focus, invalid, hover */
dialog.${this.#classNames.dialog} input:not([type='checkbox']):not([disabled]):not([readonly]):focus,
dialog.${this.#classNames.dialog} select:not([disabled]):not([readonly]):focus,
dialog.${this.#classNames.dialog} textarea:not([disabled]):not([readonly]):focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 5px var(--accent-color);
}
dialog.${this.#classNames.dialog} input:invalid,
dialog.${this.#classNames.dialog} textarea:invalid,
dialog.${this.#classNames.dialog} select:invalid {
border-color: red;
}
dialog.${this.#classNames.dialog} .${this.#classNames.footer} button {
background: none;
border: 1px solid var(--border-color-button);
padding: .4em .9em;
font-weight: 600;
transition: background-color var(--animation-duration);
color: var(--text-color);
}
dialog.${this.#classNames.dialog} .${this.#classNames.footer} button:hover {
background-color: var(--accent-color-hover);
}
@keyframes gm-config-fade-in {
from { opacity: 0; transform: scale(.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes gm-config-fade-in-backdrop {
from { opacity: 0; }
to { opacity: 1; }
}
/* Responsive */
@media (max-width: 600px) {
.${this.#classNames.body} {
grid-template-columns: 1fr;
gap: 10px 0;
}
.${this.#classNames.body} label {
justify-self: start;
margin-bottom: 4px;
}
}`;
window.GM_addStyle ? GM_addStyle(css) : document.head.append(makeElem('style', { textContent: css }));
GM_Config_UI.stylesInjected[this.prefix] = true;
}
#buildUI() {
const cfg = this.load();
const dialog = makeElem('dialog', { classes: [this.#classNames.dialog] });
const form = makeElem('form', { method: 'dialog' });
// Header
if (this.title) {
const header = makeElem('div', { classes: [this.#classNames.header] });
const titleEl = makeElem('h2', { classes: [this.#classNames.title], textContent: this.title });
const closeBtn = makeElem('button', {
type: 'submit',
value: 'close',
classes: [this.#classNames.ModalCloseBtn],
textContent: '×',
ariaLabel: 'Close'
});
header.append(titleEl, closeBtn);
form.append(header);
}
// Body
const body = makeElem('div', { classes: [this.#classNames.body] });
this.settingsArray
.filter(({ type }) => type !== 'hidden')
.forEach(setting => {
const value = cfg[setting.key];
const control = this.#createControl(setting, value);
if (control) {
const labelOptions = { textContent: setting.label, htmlFor: control.id };
if (setting.title) labelOptions.title = setting.title;
body.append(makeElem('label', labelOptions), control);
}
});
form.append(body);
// Footer
const footer = makeElem('div', { classes: [this.#classNames.footer] });
footer.append(
makeElem('button', { textContent: 'OK', type: 'submit', value: 'save' }),
makeElem('button', { textContent: 'Cancel', type: 'submit', value: 'cancel' })
);
form.append(footer);
dialog.append(form);
this.#applyDynamicStyles(form);
return dialog;
}
#createControl(setting, value) {
const { key, type, placeholder, min, max, step, values, multiline, resizable } = setting;
const commonProps = { name: key, id: `${this.prefix}-${key}` };
switch (type) {
case 'textarea':
return makeElem('textarea', { ...commonProps, value, placeholder, style: `resize:${resizable ? 'both' : 'none'};` });
case 'text':
return makeElem('input', { ...commonProps, value, placeholder });
case 'number':
return makeElem('input', { ...commonProps, type: 'number', value, placeholder, min, max, step });
case 'checkbox':
return makeElem('input', { ...commonProps, type: 'checkbox', checked: Boolean(value) });
case 'dropdown':
const select = makeElem('select', { ...commonProps });
const options = (values || []).map(opt => {
const { value: val, text } = typeof opt === 'object' ? opt : { value: opt, text: opt };
return makeElem('option', { value: val, textContent: text });
});
select.append(...options);
select.value = value;
return select;
case 'keybinding':
return this.#createKeybindingControl(commonProps, value);
default:
return null;
}
}
#createKeybindingControl(commonProps, value) {
const control = makeElem('input', { ...commonProps, type: 'text', readOnly: true, placeholder: 'Press a key combination' });
const META_KEYS = new Set(['CONTROL', 'ALT', 'SHIFT', 'META']);
const setDisplayValue = data => {
const parts = ['ctrlKey', 'altKey', 'shiftKey', 'metaKey']
.filter(k => data[k])
.map(k => k.replace('Key', '').toUpperCase());
if (data.key && !META_KEYS.has(data.key.toUpperCase())) {
parts.push(data.key.toUpperCase());
}
control.value = parts.join(' + ');
};
const initialData = { ctrlKey: false, altKey: false, shiftKey: false, metaKey: false, key: '', ...value };
Object.assign(control.dataset, initialData);
setDisplayValue(initialData);
control.addEventListener('keydown', evt => {
evt.preventDefault();
evt.stopPropagation();
if (META_KEYS.has(evt.key.toUpperCase())) return;
const keyData = { ctrlKey: evt.ctrlKey, altKey: evt.altKey, shiftKey: evt.shiftKey, metaKey: evt.metaKey, key: evt.key };
Object.assign(control.dataset, keyData);
setDisplayValue(keyData);
this.onchange?.(control.name, keyData);
});
return control;
}
#applyDynamicStyles(form) {
this.settingsArray.forEach(({ type, max, key }) => {
if (type === 'number' && max !== undefined) {
const control = form.querySelector(`[name="${key}"]`);
if (control) {
control.style.width = `${String(max).length + 5}ch`;
}
}
});
}
#collectFormData(form) {
const formData = new FormData(form);
const newCfg = this.load();
for (const { key, type } of this.settingsArray) {
if (type === 'hidden') continue;
if (type === 'keybinding') {
const { dataset } = form.querySelector(`[name="${key}"]`);
newCfg[key] = Object.fromEntries(
Object.entries(dataset).map(([prop, value]) => [prop, value === 'true' ? true : (value === 'false' ? false : value)])
);
} else if (type === 'bool') {
newCfg[key] = formData.has(key);
} else if (formData.has(key)) {
const val = formData.get(key);
newCfg[key] = type === 'number' ? Number(val) : val;
}
}
return newCfg;
}
#attachEventListeners() {
const form = this.#dialog.querySelector('form');
if (!form) return;
this.#dialog.addEventListener('close', () => {
if (this.#dialog.returnValue === 'save') {
const newCfg = this.#collectFormData(form);
this.save(newCfg);
this.onsave?.(newCfg);
} else {
this.oncancel?.(this.load());
}
this.#cleanup();
});
this.#dialog.addEventListener('click', ({ target }) => {
if (target === this.#dialog) this.#dialog.close('cancel');
});
}
}
/**
* Factory for config UI
*/
window.GM_config = (options) => {
const instance = new GM_Config_UI(options);
return {
fieldDefs: instance.settingsArray,
load: () => instance.load(),
save: (cfg) => instance.save(cfg),
setup: () => instance.setup(),
set onsave(fn) { instance.onsave = fn; },
set oncancel(fn) { instance.oncancel = fn; },
set onchange(fn) { instance.onchange = fn; }
};
};
})();
@raingart
Copy link
Author

// ==UserScript==
// ...
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// ==/UserScript==

(() => {
    'use strict';

    /**
     * Quick manual:
     * GM_config({
     *   id: 'storage-key',       // Storage key (GM_setValue/localStorage)
     *   title: 'UI Title',       // Dialog title
     *   prefix: 'css-prefix',    // CSS class prefix
     *   fields: {                // Field definitions
     *     fieldName: {
     *       label: 'Label',      // Field label
     *       type: 'text'|'number'|'checkbox'|'dropdown'|'textarea'|'keybinding',
     *       default: any,        // Default value
     *       values: [],          // For dropdown — list of values
     *       placeholder: '...',  // (opt.) placeholder
     *       min/max/step: num    // (opt.) for number type
     *     }
     *   }
     * });
     * 
     * Methods:
     *  config.load()  — load current values
     *  config.save(o) — save manually
     *  config.setup() — open the UI
     * 
     * Events:
     *  config.onsave = (cfg) => { ... };
     *  config.oncancel = (cfg) => { ... };
     *  config.onchange = (name, val) => { ... };
     */

    const keysList = [
        { value: 'KeyW', text: 'W' },
        { value: 'ArrowUp', text: 'Arrow ↑' },
       
    ];

    const config = GM_config({
        id: 'hotkey-scroll-config',
        title: 'Page Scroll Hotkeys',
        prefix: 'hps',
        fields: {
            'cal-up': {
                label: 'Calibration Up',
                default: 'KeyW',
                type: 'dropdown',
                values: keysList
            },
            'invert-scroll': {
                label: 'Invert Scroll Direction',
                default: false,
                type: 'checkbox'
            },
            'scroll-speed': {
                label: 'Scroll Speed (px)',
                default: 50,
                type: 'number',
                min: 1,
                max: 500,
                step: 5
            }
        }
    });

    const cfg = config.load();
    console.log('Loaded config:', cfg);

    // Example: open UI with Ctrl+Alt+S
    document.addEventListener('keydown', e => {
        if (e.ctrlKey && e.altKey && e.code === 'KeyS') {
            config.setup();
        }
    });

    config.onsave = newCfg => {
        console.log('Settings saved:', newCfg);
    };
    // ... rest of your script
})();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment