element-web/src/theme.ts

393 lines
14 KiB
TypeScript

/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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"),
"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: ITheme) => t.name === themeName);
if (!customTheme) {
const knownNames = customThemes.map((t: ITheme) => 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);
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();
});
}