/** * @file felexdev-theme-manager.js * @version 1.0.0 dev-in-progress (2026-06-09) * @author felex67 (admin@felexdev.ru) * @license Apache2.0 * * @brief ThemeManager - модуль-переключатель тем * @details Архитектура singleton-модуля на базе самоинициализирующегося статического класса */ class ThemeManager { /** * Состояние модуля: * При инициализации данного объекта создаётся обработчик события изменения системной темы * автоматически выставляющий поле `.isDark` в соответствующее значение. * * @property {DOMTokenList} clist: список классов ::root * @property {HTMLElement} element: ::root, тег html * @property {number} idx: индекс текущей темы * @property {boolean} isDark: флаг тёмной системной темы * @property {MediaQueryList} _schemeQuery: Медиа слушатель системной темы * @property {boolean} _schemeListener: Обработчик изменений системной темы */ static #$ = (() => { let q = window.matchMedia('(prefers-color-scheme: dark)'); let e = { clist: document.documentElement.classList, element: document.documentElement, idx: 0, isDark: q.matches, _schemeQuery: q, _schemeListener: null }; q.addEventListener('change', e._schemeListener = (function(ev) { this.isDark = ev.matches; }).bind(e)); return e; })(); /** * Конфигурация/настройки модуля * @property {string} buttonToggleId: Идентификатор кнопки-переключателя тем. 'button-toggle' * @property {string} colorSchemePrefix: Префикс селектора CSS. 'color-scheme' * @property {string} default.Image: Иконка темы по умолчанию * @property {string} globalPrefix: Глобальный префикс. 'theme-' * @property {number} menuAppearTimeout: таймаут до появления меню в милисекундах (2000), * @property {number} menuDisappearTimeout: таймауйт исчезновения меню в милисекудах (300), * @property {string} menuId: Идентификатор меню. 'theme-menu' * @property {string} menuRowClass: Класс элементов-строк меню 'theme-menu-row', * @property {string} menuImageClass: Класс иконки темы 'theme-menu-image', * @property {string} menuDescriptionClass: Класс описателя темы 'theme-menu-desc', * @property {string} storageKey: Ключ в хранилище 'theme-id', */ static #CONFIG = (function() { let c = { /* Основные настройки модуля */ buttonToggleId: 'button-toggle', // суффикс кнопки переключения тем colorSchemePrefix: 'color-scheme', // корень названия классов темы globalPrefix: 'theme', // глобальный префикс /* Настройки меню */ menuAppearTimeout: 2000, // таймаут появления меню menuDisappearTimeout: 300, // таймаут исчезновения меню menuId: 'menu', // базовый идентификатор меню menuRowClass: 'row', // базовый класс строки меню menuImageClass: 'image', // базовый класс картинки темы в меню menuDescriptionClass: 'desc', // базовый класс описательного имени темы в меню menuAreaLable: 'menu-select-theme', // описание элемента меню menuRowAreaLable: 'row', // описание элемента строки меню menuIconAreaLable: 'icon', // описание элемента иконки темы menuDescAreaLable: 'description', // описание элемента описания темы defaultImage:'', storageKey: 'id', // базовое имя ключа в хранилище }; /* Собираем строки */ c.globalPrefix += '-'; /* С этого момента globalPrefix = '${c.globalPrefix}-' */ c.storageKey = c.globalPrefix + c.storageKey; /* Логика инициализации */ /* инициализация классов меню */ c.menuId = c.globalPrefix + c.menuId; c.menuRowClass = `${c.menuId}-${c.menuRowClass}`; c.menuImageClass = `${c.menuId}-${c.menuImageClass}`; c.menuDescriptionClass = `${c.menuId}-${c.menuDescriptionClass}`; /* инициализация имени кнопки переключения тем */ c.buttonToggleId = c.globalPrefix + c.buttonToggleId; /* инициализация префикса классов в селекторе CSS */ c.colorSchemePrefix = c.globalPrefix + c.colorSchemePrefix + '-'; /* Возвращаем полностью инициализированный объект с динамически скомпонованными строками */ return c; })(); static #THEMES = []; static #save = ((idx) => { let save = (idx) => { console.error(this.#CONFIG.globalPrefix + '#save(): It is unavailable to save a theme, the storages are unreachable.'); } try { /* Пробуем localStorage */ this.#$.current = parseInt(localStorage.getItem(this.#CONFIG.storageKey)) || 0; save = (idx) => { localStorage.setItem(this.#CONFIG.storageKey, idx); }; } catch(e) { console.log(this.#CONFIG.globalPrefix + '#save-IIFE(): localStorage is unreachable, trying to use cookie.') try { let cookie = document.cookie.match(`${this.#CONFIG.storageKey}\\s*=\\s*(\\d+)`); this.#$.current = cookie ? parseInt(cookie[1]) || 0 : 0; save = (idx) => { document.cookie = `${this.#CONFIG.storageKey}=${idx}; Max-Age=${31536000}; Path=/`; }; } catch (e) { console.log(`${this.#CONFIG.globalPrefix}#save-IIFE: All storages are unreachable('localStorage' & 'document.cookie')`); } } /* Мы не дублируем, а просто навешиваем класс на `::root` ели тема не 'auto' * Это самый быстрый способ применить тему. * Не знаю как Вам, а мне - всё понятно =D */ if (this.#$.current < this.#THEMES.length) this.#$.current > 0 ? this.#$.element.classList.add(this.#THEMES[this.#$.current].class) : this.#$.current && (console.error(`${this.#CONFIG.globalPrefix}#save-IIFE: Negative theme index!!!: '${this.#$.current}'. Fallback to 'auto'`) || (this.#$.current = 0)); else console.log(`${this.#CONFIG.globalPrefix}#save-IIFE: Theme index out of range: '${this.#$.current}'. Fallback to 'auto'`); return save; })(); /** * Добавляет/заменяет/удаляет класс старой темы на новый * @param {number} idx: индекс новой темы * @param {JSObject} e: JS-объект со следующими обязательными полями: { idx: number, clist: DOMTokenList, } */ static #set(idx, e = this.#$, t = this.#THEMES) { if (idx == e.idx) return; if (idx && e.idx) e.clist.replace(t[e.idx].class, t[idx].class); else if (idx) e.clist.add(t[idx].class); else e.clist.remove(t[e.idx].class); this.#save(e.idx = idx); } /** * */ /** * Меню выбора тем * @property {class} _tm: ссылка на класс * @property {JSObject} _stt: * @property {DOMTokenList} clist: список классов управляемого элемента classList * @property {number} idx: индекс текущей темы меню * @property {number} tmh: таймер сокрытия меню * @property {number} tms: таймер показа меню * @property {JSObject} _t: объект темы * @property {DOMTokenList} clist: список классов управляемого элемента classList * @property {number} idx: индекс текущей темы меню * @property {number} tmh: таймер сокрытия меню * @property {number} tms: таймер показа меню * */ static #MENU = (() => { let menu = { /* private: */ /* Свойства */ _tm: this, // ссылка на основной класс /** * {JSObject} `static #MENU._stt` * Внутреннее состояние меню * @property {DOMTokenList} clist: список классов управляемого элемента classList * @property {number} idx: индекс текущей темы меню * @property {number} tmh: таймер сокрытия меню * @property {number} tms: таймер показа меню */ _stt: { clist: null, // classList of element idx: 0, // текущая тема меню tmh: null, // таймер сокрытия меню tms: null, // таймер показа меню }, _t: this.#THEMES, // ссылка на массив тем /* Методы */ _tv: null, // переключить видимость /* public: */ /* Методы */ appendChild: null, // `div.appendChild` div: document.createElement('div'), // ссылка на контролируемый DOM-элемент hide: null, // спрятать меню show: null, // показать меню style: null, // `div.style` }; /* Инициализируем div-элемент */ menu.div.id = this.#CONFIG.menuId; // устанавливаем id menu._stt.clist = menu.div.classList; // привязываем список классов /* Пробрасываем часто нужные методы и поля div-элемента */ menu.appendChild = menu.div.appendChild.bind(menu._state.element); menu.style = menu._state.element.style; /* Внутренняя логика меню */ /* Спрятать меню */ menu.hide = (function() { this.style.width = this.style.height = '0px'; }).bind(menu); /* Показать меню */ menu.show = (function() { let s = this._themes[this._manager.#current].sizes; this.style.visibility = 'visible'; this.style.width = s.w; this.style.height = s.h; }).bind(menu); /* Применяем тему ко всему документу */ menu.apply = (function(idx) { this._manager.#set(idx, this._state); this.hide(); /* Это необходимо для того чтобы меню подхватило стиль всего документа */ this._remove(this.themes[this._current].name); this._current = 0; }).bind(menu); return menu; })(); /** * @typedef {Object} ThemeMenuSizes - Фактические размеры меню при использовании темы * @property {string} w: ширина меню в пикселях * @property {string} h: высота меню в пикселях */ /** * @typedef {Object} Theme * @property {string} name: Внутреннее название темы `'auto'` * @property {string} class: Селектор CSS * @property {number} idx: Индекс темы в массиве `#THEMES` * @property {HTMLDivElement} row: Строка меню * @property {HTMLDivElement} icon: Иконка темы * @property {HTMLDivElement} desc: Описательное имя темы * @property {ThemeMenuSizes} sizes: Фактические размеры меню при применении темы в пикселях */ /** * Создаёт и инициализирует новый объект темы * @param {string} name - внутреннее название темы. * @param {string?} desc - описательное название темы. `{ desc: desc || name, }` * @param {string?} svg - векторное изображение темы. `{ svg: svg || this.#CONFIG.defaultImg, }` * * @returns {Theme} - инициализированный объект темы * * @throws {Error} при `!name.length || typeof(name) != 'string'` * @throws {Error} если тема с таким 'name' уже существует */ static #newTheme = (name, desc = null, svg = null) => { if ((typeof name) != 'string' || !name) { throw new Error(`${this.#CONFIG.globalPrefix}#newTheme: empty or invalid theme name: type: ${typeof name}, val: '${name}'`) } else if (-1 != this.#THEMES.indexOf(name)) { throw new Error(`${this.#CONFIG.globalPrefix}#newTheme: theme '${name}' allready exists`); } let theme = { menu: this.#MENU, name: name, // Имя темы class: this.#CONFIG.colorSchemePrefix + name, // Класс в CSS-селекторе idx: idx, // Индекс темы в массиве тем row: document.createElement('div'), // Элемент строки icon: document.createElement('div'), // Элемент иконки desc: document.createElement('div'), // Элемент описания sizes: { w: '0px', h: '0px' }, // Размеры меню при установке темы(для анимации) }; /* настраиваем классы элементов */ theme.row.classList.add(this.#CONFIG.menuRowClass); theme.icon.classList.add(this.#CONFIG.menuImageClass); theme.desc.classList.add(this.#CONFIG.menuDescriptionClass); /* заполняем иконку и описание */ theme.icon.innerHTML = svg || this.#CONFIG.defaultImage; theme.desc.innerText = desc || name; /* собираем строку меню */ theme.row.appendChild(theme.icon); theme.row.appendChild(theme.desc); /* настраиваем обработчики */ theme.row.addEventListener('mouseenter', (function () { this.menu.preview(this.idx); }).bind(theme)); theme.row.addEventListener('focusin', (function () { this.menu.preview(this.idx); }).bind(theme)); this.#MENU.appendChild(theme.row); theme.idx = this.#THEMES.push(theme) - 1; return theme; } static #initDeafultThemes = (() => { this.#newTheme( 'auto', 'Системная', '', ); this.#newTheme( 'light', 'Светлая', '', ); this.#newTheme( 'dark', 'Тёмная', '', ); this.#newTheme( 'old-terminal', 'Старый терминал', '', ); }); static #_ = (() => { this.#initDeafultThemes(); console.log(this.#CONFIG); console.log(this.#MENU); })(); };