Last active
August 13, 2025 08:27
-
-
Save raingart/23f06b1fb9cf875bc3c19621af43fb88 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (() => { | |
| '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; } | |
| }; | |
| }; | |
| })(); |
Author
raingart
commented
Aug 13, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment