Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19/**
20 * This class is used to control theme attributes on DOM elements.
21 */
22export class ThemeController {
23 /**
24 * Creates a new instance of the class.
25 * @param {?HTMLElement} element A DOM element which theme properties are applied to.
26 */
27 constructor(element) {
28 /** @type {?HTMLElement} */
29 this._element = element;
30 /** @type {import("settings.js").PopupTheme} */
31 this._theme = 'site';
32 /** @type {import("settings.js").PopupOuterTheme} */
33 this._outerTheme = 'site';
34 /** @type {?('dark'|'light')} */
35 this._siteTheme = null;
36 /** @type {'dark'|'light'} */
37 this._browserTheme = 'light';
38 /** @type {boolean} */
39 this.siteOverride = false;
40 }
41
42 /**
43 * Gets the DOM element which theme properties are applied to.
44 * @type {?Element}
45 */
46 get element() {
47 return this._element;
48 }
49
50 /**
51 * Sets the DOM element which theme properties are applied to.
52 * @param {?HTMLElement} value The DOM element to assign.
53 */
54 set element(value) {
55 this._element = value;
56 }
57
58 /**
59 * Gets the main theme for the content.
60 * @type {import("settings.js").PopupTheme}
61 */
62 get theme() {
63 return this._theme;
64 }
65
66 /**
67 * Sets the main theme for the content.
68 * @param {import("settings.js").PopupTheme} value The theme value to assign.
69 */
70 set theme(value) {
71 this._theme = value;
72 }
73
74 /**
75 * Gets the outer theme for the content.
76 * @type {import("settings.js").PopupOuterTheme}
77 */
78 get outerTheme() {
79 return this._outerTheme;
80 }
81
82 /**
83 * Sets the outer theme for the content.
84 * @param {import("settings.js").PopupOuterTheme} value The outer theme value to assign.
85 */
86 set outerTheme(value) {
87 this._outerTheme = value;
88 }
89
90 /**
91 * Gets the override value for the site theme.
92 * If this value is `null`, the computed value will be used.
93 * @type {?('dark'|'light')}
94 */
95 get siteTheme() {
96 return this._siteTheme;
97 }
98
99 /**
100 * Sets the override value for the site theme.
101 * If this value is `null`, the computed value will be used.
102 * @param {?('dark'|'light')} value The site theme value to assign.
103 */
104 set siteTheme(value) {
105 this._siteTheme = value;
106 }
107
108 /**
109 * Gets the browser's preferred color theme.
110 * The value can be either 'light' or 'dark'.
111 * @type {'dark'|'light'}
112 */
113 get browserTheme() {
114 return this._browserTheme;
115 }
116
117 /**
118 * Prepares the instance for use and applies the theme settings.
119 */
120 prepare() {
121 const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
122 mediaQueryList.addEventListener('change', this._onPrefersColorSchemeDarkChange.bind(this));
123 this._onPrefersColorSchemeDarkChange(mediaQueryList);
124 }
125
126 /**
127 * Updates the theme attributes on the target element.
128 * If the site theme value isn't overridden, the current site theme is recomputed.
129 */
130 updateTheme() {
131 if (this._element === null) { return; }
132 const computedSiteTheme = this._siteTheme !== null ? this._siteTheme : this.computeSiteTheme();
133 const data = this._element.dataset;
134 data.theme = this._resolveThemeValue(this._theme, computedSiteTheme);
135 data.outerTheme = this._resolveThemeValue(this._outerTheme, computedSiteTheme);
136 data.siteTheme = computedSiteTheme;
137 data.browserTheme = this._browserTheme;
138 data.themeRaw = this._theme;
139 data.outerThemeRaw = this._outerTheme;
140 }
141
142 /**
143 * Computes the current site theme based on the background color.
144 * @returns {'light'|'dark'} The theme of the site.
145 */
146 computeSiteTheme() {
147 const color = [255, 255, 255];
148 const {documentElement, body} = document;
149 if (documentElement !== null) {
150 this._addColor(color, window.getComputedStyle(documentElement).backgroundColor);
151 }
152 if (body !== null) {
153 this._addColor(color, window.getComputedStyle(body).backgroundColor);
154 }
155 const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128);
156 return dark ? 'dark' : 'light';
157 }
158
159 /**
160 * Event handler for when the preferred browser theme changes.
161 * @param {MediaQueryList|MediaQueryListEvent} detail The object containing event details.
162 */
163 _onPrefersColorSchemeDarkChange({matches}) {
164 this._browserTheme = (matches ? 'dark' : 'light');
165 this.updateTheme();
166 }
167
168 /**
169 * Resolves a settings theme value to the actual value which should be used.
170 * @param {string} theme The theme value to resolve.
171 * @param {string} computedSiteTheme The computed site theme value to use for when the theme value is `'auto'`.
172 * @returns {string} The resolved theme value.
173 */
174 _resolveThemeValue(theme, computedSiteTheme) {
175 switch (theme) {
176 case 'site': return this.siteOverride ? this._browserTheme : computedSiteTheme;
177 case 'browser': return this._browserTheme;
178 default: return theme;
179 }
180 }
181
182 /**
183 * Adds the value of a CSS color to an accumulation target.
184 * @param {number[]} target The target color buffer to accumulate into, as an array of [r, g, b].
185 * @param {string|*} cssColor The CSS color value to add to the target. If this value is not a string,
186 * the target will not be modified.
187 */
188 _addColor(target, cssColor) {
189 if (typeof cssColor !== 'string') { return; }
190
191 const color = this._getColorInfo(cssColor);
192 if (color === null) { return; }
193
194 const a = color[3];
195 if (a <= 0) { return; }
196
197 const aInv = 1 - a;
198 for (let i = 0; i < 3; ++i) {
199 target[i] = target[i] * aInv + color[i] * a;
200 }
201 }
202
203 /**
204 * Decomposes a CSS color string into its RGBA values.
205 * @param {string} cssColor The color value to decompose. This value is expected to be in the form RGB(r, g, b) or RGBA(r, g, b, a).
206 * @returns {?number[]} The color and alpha values as [r, g, b, a]. The color component values range from [0, 255], and the alpha ranges from [0, 1].
207 */
208 _getColorInfo(cssColor) {
209 const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor);
210 if (m === null) { return null; }
211
212 const m4 = m[4];
213 return [
214 Number.parseInt(m[1], 10),
215 Number.parseInt(m[2], 10),
216 Number.parseInt(m[3], 10),
217 m4 ? Math.max(0, Math.min(1, Number.parseFloat(m4))) : 1,
218 ];
219 }
220}