From 5218e7677a6494d8d1a5fb3658646e448aa383dc Mon Sep 17 00:00:00 2001 From: felex67 Date: Fri, 19 Jun 2026 01:01:23 +0500 Subject: [PATCH] Initial commit --- html/head.html | 8 + js/felexdev-theme-manager-mini.js | 12 ++ js/felexdev_theme_manager.js | 303 ++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 html/head.html create mode 100644 js/felexdev-theme-manager-mini.js create mode 100644 js/felexdev_theme_manager.js diff --git a/html/head.html b/html/head.html new file mode 100644 index 0000000..6fb3874 --- /dev/null +++ b/html/head.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/js/felexdev-theme-manager-mini.js b/js/felexdev-theme-manager-mini.js new file mode 100644 index 0000000..e178eb4 --- /dev/null +++ b/js/felexdev-theme-manager-mini.js @@ -0,0 +1,12 @@ +/** + * @file felexdev-theme-manager-mini.js + * @version 1.0.0 dev-in-progress (2026-06-09) + * @author felex67 (admin@felexdev.ru) + * @license Apache2.0 + * + * Language standart: ECMAScript 2022 (ES13) + * + * @brief ThemeManager - модуль-переключатель тем + * @details модуль переклюатель тем + */ +class ThemeManager {}; \ No newline at end of file diff --git a/js/felexdev_theme_manager.js b/js/felexdev_theme_manager.js new file mode 100644 index 0000000..970651e --- /dev/null +++ b/js/felexdev_theme_manager.js @@ -0,0 +1,303 @@ +/** + * @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); + })(); +};