summaryrefslogtreecommitdiff
path: root/ext/js/app/theme-controller.js
blob: 559a6e57d1d11a0c0f65285597d2628c4f7f14e5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
/*
 * Copyright (C) 2023-2024  Yomitan Authors
 * Copyright (C) 2022  Yomichan Authors
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

/**
 * This class is used to control theme attributes on DOM elements.
 */
export class ThemeController {
    /**
     * Creates a new instance of the class.
     * @param {?HTMLElement} element A DOM element which theme properties are applied to.
     */
    constructor(element) {
        /** @type {?HTMLElement} */
        this._element = element;
        /** @type {'light'|'dark'|'browser'} */
        this._theme = 'light';
        /** @type {'light'|'dark'|'browser'|'site'} */
        this._outerTheme = 'light';
        /** @type {?('dark'|'light')} */
        this._siteTheme = null;
        /** @type {'dark'|'light'} */
        this._browserTheme = 'light';
    }

    /**
     * Gets the DOM element which theme properties are applied to.
     * @type {?Element}
     */
    get element() {
        return this._element;
    }

    /**
     * Sets the DOM element which theme properties are applied to.
     * @param {?HTMLElement} value The DOM element to assign.
     */
    set element(value) {
        this._element = value;
    }

    /**
     * Gets the main theme for the content.
     * @type {'light'|'dark'|'browser'}
     */
    get theme() {
        return this._theme;
    }

    /**
     * Sets the main theme for the content.
     * @param {'light'|'dark'|'browser'} value The theme value to assign.
     */
    set theme(value) {
        this._theme = value;
    }

    /**
     * Gets the outer theme for the content.
     * @type {'light'|'dark'|'browser'|'site'}
     */
    get outerTheme() {
        return this._outerTheme;
    }

    /**
     * Sets the outer theme for the content.
     * @param {'light'|'dark'|'browser'|'site'} value The outer theme value to assign.
     */
    set outerTheme(value) {
        this._outerTheme = value;
    }

    /**
     * Gets the override value for the site theme.
     * If this value is `null`, the computed value will be used.
     * @type {?('dark'|'light')}
     */
    get siteTheme() {
        return this._siteTheme;
    }

    /**
     * Sets the override value for the site theme.
     * If this value is `null`, the computed value will be used.
     * @param {?('dark'|'light')} value The site theme value to assign.
     */
    set siteTheme(value) {
        this._siteTheme = value;
    }

    /**
     * Gets the browser's preferred color theme.
     * The value can be either 'light' or 'dark'.
     * @type {'dark'|'light'}
     */
    get browserTheme() {
        return this._browserTheme;
    }

    /**
     * Prepares the instance for use and applies the theme settings.
     */
    prepare() {
        const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
        mediaQueryList.addEventListener('change', this._onPrefersColorSchemeDarkChange.bind(this));
        this._onPrefersColorSchemeDarkChange(mediaQueryList);
    }

    /**
     * Updates the theme attributes on the target element.
     * If the site theme value isn't overridden, the current site theme is recomputed.
     */
    updateTheme() {
        if (this._element === null) { return; }
        const computedSiteTheme = this._siteTheme !== null ? this._siteTheme : this.computeSiteTheme();
        const data = this._element.dataset;
        data.theme = this._resolveThemeValue(this._theme, computedSiteTheme);
        data.outerTheme = this._resolveThemeValue(this._outerTheme, computedSiteTheme);
        data.siteTheme = computedSiteTheme;
        data.browserTheme = this._browserTheme;
        data.themeRaw = this._theme;
        data.outerThemeRaw = this._outerTheme;
    }

    /**
     * Computes the current site theme based on the background color.
     * @returns {'light'|'dark'} The theme of the site.
     */
    computeSiteTheme() {
        const color = [255, 255, 255];
        const {documentElement, body} = document;
        if (documentElement !== null) {
            this._addColor(color, window.getComputedStyle(documentElement).backgroundColor);
        }
        if (body !== null) {
            this._addColor(color, window.getComputedStyle(body).backgroundColor);
        }
        const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128);
        return dark ? 'dark' : 'light';
    }

    /**
     * Event handler for when the preferred browser theme changes.
     * @param {MediaQueryList|MediaQueryListEvent} detail The object containing event details.
     */
    _onPrefersColorSchemeDarkChange({matches}) {
        this._browserTheme = (matches ? 'dark' : 'light');
        this.updateTheme();
    }

    /**
     * Resolves a settings theme value to the actual value which should be used.
     * @param {string} theme The theme value to resolve.
     * @param {string} computedSiteTheme The computed site theme value to use for when the theme value is `'auto'`.
     * @returns {string} The resolved theme value.
     */
    _resolveThemeValue(theme, computedSiteTheme) {
        switch (theme) {
            case 'auto': return computedSiteTheme;
            case 'browser': return this._browserTheme;
            default: return theme;
        }
    }

    /**
     * Adds the value of a CSS color to an accumulation target.
     * @param {number[]} target The target color buffer to accumulate into, as an array of [r, g, b].
     * @param {string|*} cssColor The CSS color value to add to the target. If this value is not a string,
     *   the target will not be modified.
     */
    _addColor(target, cssColor) {
        if (typeof cssColor !== 'string') { return; }

        const color = this._getColorInfo(cssColor);
        if (color === null) { return; }

        const a = color[3];
        if (a <= 0.0) { return; }

        const aInv = 1.0 - a;
        for (let i = 0; i < 3; ++i) {
            target[i] = target[i] * aInv + color[i] * a;
        }
    }

    /**
     * Decomposes a CSS color string into its RGBA values.
     * @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).
     * @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].
     */
    _getColorInfo(cssColor) {
        const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor);
        if (m === null) { return null; }

        const m4 = m[4];
        return [
            Number.parseInt(m[1], 10),
            Number.parseInt(m[2], 10),
            Number.parseInt(m[3], 10),
            m4 ? Math.max(0.0, Math.min(1.0, Number.parseFloat(m4))) : 1.0
        ];
    }
}