This repository was archived by the owner on Feb 11, 2026. It is now read-only.
forked from element-hq/element-web
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy paththeme.ts
More file actions
399 lines (355 loc) · 14.2 KB
/
theme.ts
File metadata and controls
399 lines (355 loc) · 14.2 KB
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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import "@fontsource/inter/400.css";
import "@fontsource/inter/400-italic.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/500-italic.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/600-italic.css";
import "@fontsource/inter/700.css";
import "@fontsource/inter/700-italic.css";
import "@fontsource/inconsolata/latin-ext-400.css";
import "@fontsource/inconsolata/latin-400.css";
import "@fontsource/inconsolata/latin-ext-700.css";
import "@fontsource/inconsolata/latin-700.css";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "./languageHandler";
import SettingsStore from "./settings/SettingsStore";
import ThemeWatcher from "./settings/watchers/ThemeWatcher";
export const DEFAULT_THEME = "light";
const HIGH_CONTRAST_THEMES: Record<string, string> = {
light: "light-high-contrast",
};
interface IFontFaces extends Omit<Record<(typeof allowedFontFaceProps)[number], string>, "src"> {
src: {
format: string;
url: string;
local: string;
}[];
}
interface CompoundTheme {
[token: string]: string;
}
export type CustomTheme = {
name: string;
is_dark?: boolean; // eslint-disable-line camelcase
colors?: {
[key: string]: string;
};
fonts?: {
faces: IFontFaces[];
general: string;
monospace: string;
};
compound?: CompoundTheme;
};
/**
* Given a non-high-contrast theme, find the corresponding high-contrast one
* if it exists, or return undefined if not.
*/
export function findHighContrastTheme(theme: string): string | undefined {
return HIGH_CONTRAST_THEMES[theme];
}
/**
* Given a high-contrast theme, find the corresponding non-high-contrast one
* if it exists, or return undefined if not.
*/
export function findNonHighContrastTheme(hcTheme: string): string | undefined {
for (const theme in HIGH_CONTRAST_THEMES) {
if (HIGH_CONTRAST_THEMES[theme] === hcTheme) {
return theme;
}
}
}
/**
* Decide whether the supplied theme is high contrast.
*/
export function isHighContrastTheme(theme: string): boolean {
return Object.values(HIGH_CONTRAST_THEMES).includes(theme);
}
export function enumerateThemes(): { [key: string]: string } {
const BUILTIN_THEMES = {
// "light": _t("common|light"), /* elecord, remove default light theme */
"light-high-contrast": _t("theme|light_high_contrast"),
"dark": _t("common|dark"),
};
const customThemes = SettingsStore.getValue("custom_themes") || [];
const customThemeNames: Record<string, string> = {};
try {
for (const { name } of customThemes) {
customThemeNames[`custom-${name}`] = name;
}
} catch (err) {
logger.warn("Error loading custom themes", {
err,
customThemes,
});
}
return Object.assign({}, customThemeNames, BUILTIN_THEMES);
}
export interface ITheme {
id: string;
name: string;
}
export function getOrderedThemes(): ITheme[] {
const themes = Object.entries(enumerateThemes())
.map((p) => ({ id: p[0], name: p[1] })) // convert pairs to objects for code readability
.filter((p) => !isHighContrastTheme(p.id));
const builtInThemes = themes.filter((p) => !p.id.startsWith("custom-"));
const collator = new Intl.Collator();
const customThemes = themes
.filter((p) => !builtInThemes.includes(p))
.sort((a, b) => collator.compare(a.name, b.name));
return [...builtInThemes, ...customThemes];
}
function clearCustomTheme(): void {
// remove all css variables, we assume these are there because of the custom theme
const inlineStyleProps = Object.values(document.body.style);
for (const prop of inlineStyleProps) {
if (prop.startsWith("--")) {
document.body.style.removeProperty(prop);
}
}
// remove the custom style sheets
document.querySelector("head > style[title='custom-theme-font-faces']")?.remove();
document.querySelector("head > style[title='custom-theme-compound']")?.remove();
}
const allowedFontFaceProps = [
"font-display",
"font-family",
"font-stretch",
"font-style",
"font-weight",
"font-variant",
"font-feature-settings",
"font-variation-settings",
"src",
"unicode-range",
] as const;
function generateCustomFontFaceCSS(faces: IFontFaces[]): string {
return faces
.map((face) => {
const src = face.src
?.map((srcElement) => {
let format = "";
if (srcElement.format) {
format = `format("${srcElement.format}")`;
}
if (srcElement.url) {
return `url("${srcElement.url}") ${format}`;
} else if (srcElement.local) {
return `local("${srcElement.local}") ${format}`;
}
return "";
})
.join(", ");
const props = Object.keys(face).filter((prop) =>
allowedFontFaceProps.includes(prop as (typeof allowedFontFaceProps)[number]),
) as Array<(typeof allowedFontFaceProps)[number]>;
const body = props
.map((prop) => {
let value: string;
if (prop === "src") {
value = src;
} else if (prop === "font-family") {
value = `"${face[prop]}"`;
} else {
value = face[prop];
}
return `${prop}: ${value}`;
})
.join(";");
return `@font-face {${body}}`;
})
.join("\n");
}
const COMPOUND_TOKEN = /^--cpd-[a-z0-9-]+$/;
/**
* Generates a style sheet to override Compound design tokens as specified in
* the given theme.
*/
function generateCustomCompoundCSS(theme: CompoundTheme): string {
const properties: string[] = [];
for (const [token, value] of Object.entries(theme))
if (COMPOUND_TOKEN.test(token)) properties.push(`${token}: ${value};`);
else logger.warn(`'${token}' is not a valid Compound token`);
// Insert the design token overrides into the 'custom' cascade layer as
// documented at https://compound.element.io/?path=/docs/develop-theming--docs
return `@layer compound.custom { :root, [class*="cpd-theme-"] { ${properties.join(" ")} } }`;
}
function setCustomThemeVars(customTheme: CustomTheme): void {
const { style } = document.body;
function setCSSColorVariable(name: string, hexColor: string, doPct = true): void {
style.setProperty(`--${name}`, hexColor);
if (doPct) {
// uses #rrggbbaa to define the color with alpha values at 0%, 15% and 50%
style.setProperty(`--${name}-0pct`, hexColor + "00");
style.setProperty(`--${name}-15pct`, hexColor + "26");
style.setProperty(`--${name}-50pct`, hexColor + "7F");
}
}
if (customTheme.colors) {
for (const [name, value] of Object.entries(customTheme.colors)) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i += 1) {
setCSSColorVariable(`${name}_${i}`, value[i], false);
}
} else {
setCSSColorVariable(name, value);
}
}
}
if (customTheme.fonts) {
const { fonts } = customTheme;
if (fonts.faces) {
const css = generateCustomFontFaceCSS(fonts.faces);
const style = document.createElement("style");
style.setAttribute("title", "custom-theme-font-faces");
style.setAttribute("type", "text/css");
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
}
if (fonts.general) {
style.setProperty("--font-family", fonts.general);
}
if (fonts.monospace) {
style.setProperty("--font-family-monospace", fonts.monospace);
}
}
if (customTheme.compound) {
const css = generateCustomCompoundCSS(customTheme.compound);
const style = document.createElement("style");
style.setAttribute("title", "custom-theme-compound");
style.setAttribute("type", "text/css");
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
}
}
export function getCustomTheme(themeName: string): CustomTheme {
// set css variables
const customThemes = SettingsStore.getValue("custom_themes");
if (!customThemes) {
throw new Error(`No custom themes set, can't set custom theme "${themeName}"`);
}
const customTheme = customThemes.find((t: CustomTheme) => t.name === themeName);
if (!customTheme) {
const knownNames = customThemes.map((t: CustomTheme) => t.name).join(", ");
throw new Error(`Can't find custom theme "${themeName}", only know ${knownNames}`);
}
return customTheme;
}
/**
* Called whenever someone changes the theme
* Async function that returns once the theme has been set
* (ie. the CSS has been loaded)
*
* @param {string} theme new theme
*/
export async function setTheme(theme?: string): Promise<void> {
if (!theme) {
const themeWatcher = new ThemeWatcher();
theme = themeWatcher.getEffectiveTheme();
}
clearCustomTheme();
let stylesheetName = theme;
if (theme.startsWith("custom-")) {
const customTheme = getCustomTheme(theme.slice(7));
stylesheetName = customTheme.is_dark ? "dark-custom" : "light-custom";
setCustomThemeVars(customTheme);
}
// look for the stylesheet elements.
// styleElements is a map from style name to HTMLLinkElement.
const styleElements = new Map<string, HTMLLinkElement>();
const themes = Array.from(document.querySelectorAll<HTMLLinkElement>("[data-mx-theme]"));
themes.forEach((theme) => {
styleElements.set(theme.dataset.mxTheme!.toLowerCase(), theme);
});
if (!styleElements.has(stylesheetName)) {
throw new Error("Unknown theme " + stylesheetName);
}
// disable all of them first, then enable the one we want. Chrome only
// bothers to do an update on a true->false transition, so this ensures
// that we get exactly one update, at the right time.
//
// ^ This comment was true when we used to use alternative stylesheets
// for the CSS. Nowadays we just set them all as disabled in index.html
// and enable them as needed. It might be cleaner to disable them all
// at the same time to prevent loading two themes simultaneously and
// having them interact badly... but this causes a flash of unstyled app
// which is even uglier. So we don't.
const styleSheet = styleElements.get(stylesheetName)!;
styleSheet.disabled = false;
/**
* Adds the Compound theme class to the top-most element in the document
* This will automatically refresh the colour scales based on the OS or user
* preferences
*/
document.body.classList.remove("cpd-theme-light", "cpd-theme-dark", "cpd-theme-light-hc", "cpd-theme-dark-hc");
let compoundThemeClassName = `cpd-theme-` + (stylesheetName.includes("light") ? "light" : "dark");
// Always respect user OS preference!
if (isHighContrastTheme(theme) || window.matchMedia("(prefers-contrast: more)").matches) {
compoundThemeClassName += "-hc";
}
document.body.classList.add(compoundThemeClassName);
return new Promise((resolve, reject) => {
const switchTheme = function (): void {
// we re-enable our theme here just in case we raced with another
// theme set request as per https://github.com/vector-im/element-web/issues/5601.
// We could alternatively lock or similar to stop the race, but
// this is probably good enough for now.
styleSheet.disabled = false;
styleElements.forEach((a) => {
if (a == styleSheet) return;
a.disabled = true;
});
const bodyStyles = global.getComputedStyle(document.body);
// disable meta theme color, done statically in index.html
// if (bodyStyles.backgroundColor) {
// const metaElement = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')!;
// metaElement.content = bodyStyles.backgroundColor;
// }
resolve();
};
const isStyleSheetLoaded = (): boolean =>
Boolean([...document.styleSheets].find((_styleSheet) => _styleSheet?.href === styleSheet.href));
function waitForStyleSheetLoading(): void {
// turns out that Firefox preloads the CSS for link elements with
// the disabled attribute, but Chrome doesn't.
if (isStyleSheetLoaded()) {
switchTheme();
return;
}
let counter = 0;
// In case of theme toggling (white => black => white)
// Chrome doesn't fire the `load` event when the white theme is selected the second times
const intervalId = window.setInterval(() => {
if (isStyleSheetLoaded()) {
clearInterval(intervalId);
styleSheet.onload = null;
styleSheet.onerror = null;
switchTheme();
}
// Avoid to be stuck in an endless loop if there is an issue in the stylesheet loading
counter++;
if (counter === 10) {
clearInterval(intervalId);
reject();
}
}, 200);
styleSheet.onload = () => {
clearInterval(intervalId);
switchTheme();
};
styleSheet.onerror = (e) => {
clearInterval(intervalId);
reject(e);
};
}
waitForStyleSheetLoading();
});
}