242 lines
8.4 KiB
JavaScript
242 lines
8.4 KiB
JavaScript
/*
|
|
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 { _t } from "./languageHandler";
|
|
|
|
export const DEFAULT_THEME = "light";
|
|
import SettingsStore from "./settings/SettingsStore";
|
|
import ThemeWatcher from "./settings/watchers/ThemeWatcher";
|
|
|
|
export function enumerateThemes() {
|
|
const BUILTIN_THEMES = {
|
|
"light": _t("Light"),
|
|
"dark": _t("Dark"),
|
|
};
|
|
const customThemes = SettingsStore.getValue("custom_themes");
|
|
const customThemeNames = {};
|
|
for (const { name } of customThemes) {
|
|
customThemeNames[`custom-${name}`] = name;
|
|
}
|
|
return Object.assign({}, customThemeNames, BUILTIN_THEMES);
|
|
}
|
|
|
|
function clearCustomTheme() {
|
|
// 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);
|
|
}
|
|
}
|
|
const customFontFaceStyle = document.querySelector("head > style[title='custom-theme-font-faces']");
|
|
if (customFontFaceStyle) {
|
|
customFontFaceStyle.remove();
|
|
}
|
|
}
|
|
|
|
const allowedFontFaceProps = [
|
|
"font-display",
|
|
"font-family",
|
|
"font-stretch",
|
|
"font-style",
|
|
"font-weight",
|
|
"font-variant",
|
|
"font-feature-settings",
|
|
"font-variation-settings",
|
|
"src",
|
|
"unicode-range",
|
|
];
|
|
|
|
function generateCustomFontFaceCSS(faces) {
|
|
return faces.map(face => {
|
|
const src = face.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));
|
|
const body = props.map(prop => {
|
|
let value;
|
|
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");
|
|
}
|
|
|
|
function setCustomThemeVars(customTheme) {
|
|
const { style } = document.body;
|
|
|
|
function setCSSColorVariable(name, hexColor, doPct = true) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getCustomTheme(themeName) {
|
|
// 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 => t.name === themeName);
|
|
if (!customTheme) {
|
|
const knownNames = customThemes.map(t => 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) {
|
|
if (!theme) {
|
|
const themeWatcher = new ThemeWatcher();
|
|
theme = themeWatcher.getEffectiveTheme();
|
|
}
|
|
clearCustomTheme();
|
|
let stylesheetName = theme;
|
|
if (theme.startsWith("custom-")) {
|
|
const customTheme = getCustomTheme(theme.substr(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 = Object.create(null);
|
|
let a;
|
|
for (let i = 0; (a = document.getElementsByTagName("link")[i]); i++) {
|
|
const href = a.getAttribute("href");
|
|
// shouldn't we be using the 'title' tag rather than the href?
|
|
const match = href && href.match(/^bundles\/.*\/theme-(.*)\.css$/);
|
|
if (match) {
|
|
styleElements[match[1]] = a;
|
|
}
|
|
}
|
|
|
|
if (!(stylesheetName in styleElements)) {
|
|
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.
|
|
|
|
styleElements[stylesheetName].disabled = false;
|
|
|
|
return new Promise((resolve) => {
|
|
const switchTheme = function() {
|
|
// 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.
|
|
styleElements[stylesheetName].disabled = false;
|
|
Object.values(styleElements).forEach((a) => {
|
|
if (a == styleElements[stylesheetName]) return;
|
|
a.disabled = true;
|
|
});
|
|
const bodyStyles = global.getComputedStyle(document.body);
|
|
if (bodyStyles.backgroundColor) {
|
|
document.querySelector('meta[name="theme-color"]').content = bodyStyles.backgroundColor;
|
|
}
|
|
resolve();
|
|
};
|
|
|
|
// turns out that Firefox preloads the CSS for link elements with
|
|
// the disabled attribute, but Chrome doesn't.
|
|
|
|
let cssLoaded = false;
|
|
|
|
styleElements[stylesheetName].onload = () => {
|
|
switchTheme();
|
|
};
|
|
|
|
for (let i = 0; i < document.styleSheets.length; i++) {
|
|
const ss = document.styleSheets[i];
|
|
if (ss && ss.href === styleElements[stylesheetName].href) {
|
|
cssLoaded = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (cssLoaded) {
|
|
styleElements[stylesheetName].onload = undefined;
|
|
switchTheme();
|
|
}
|
|
});
|
|
}
|