diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts new file mode 100644 index 00000000..313a8183 --- /dev/null +++ b/src/platform/web/ThemeBuilder.ts @@ -0,0 +1,155 @@ +/* +Copyright 2020 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 type {ThemeInformation} from "./ThemeLoader"; +import {ColorSchemePreference} from "./ThemeLoader"; +import {offColor} from 'off-color'; + +function derive(value, operation, argument, isDark) { + const argumentAsNumber = parseInt(argument); + if (isDark) { + // For dark themes, invert the operation + if (operation === 'darker') { + operation = "lighter"; + } + else if (operation === 'lighter') { + operation = "darker"; + } + } + switch (operation) { + case "darker": { + const newColorString = offColor(value).darken(argumentAsNumber / 100).hex(); + return newColorString; + } + case "lighter": { + const newColorString = offColor(value).lighten(argumentAsNumber / 100).hex(); + return newColorString; + } + } +} + +export class ThemeBuilder { + // todo: replace any with manifest type when PR is merged + private _idToManifest: Map; + private _themeMapping: Record = {}; + private _themeToVariables: Record = {}; + private _preferredColorScheme?: ColorSchemePreference; + + constructor(manifestMap: Map, preferredColorScheme?: ColorSchemePreference) { + this._idToManifest = manifestMap; + this._preferredColorScheme = preferredColorScheme; + } + + populateDerivedTheme(manifest) { + const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends); + const runtimeCssLocation = baseManifest.source?.["runtime-asset"]; + const cssLocation = new URL(runtimeCssLocation, new URL(location, window.location.origin)).href; + const derivedVariables = baseManifest.source?.["derived-variables"]; + const themeName = manifest.name; + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (const [variant, variantDetails] of Object.entries(manifest.values.variants) as [string, any][]) { + const themeId = `${manifest.id}-${variant}`; + const { name: variantName, default: isDefault, dark, variables } = variantDetails; + const resolvedVariables = this.deriveVariables(variables, derivedVariables, dark); + console.log("resolved", resolvedVariables); + Object.assign(variables, resolvedVariables); + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + /** + * This is a default variant! + * We'll add these to the themeMapping (separately) keyed with just the + * theme-name (i.e "Element" instead of "Element Dark"). + * We need to be able to distinguish them from other variants! + * + * This allows us to render radio-buttons with "dark" and + * "light" options. + */ + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + defaultVariant.variantName = variantName; + defaultVariant.id = themeId; + defaultVariant.cssLocation = cssLocation; + defaultVariant.variables = variables; + continue; + } + // Non-default variants are keyed in themeMapping with "theme_name variant_name" + // eg: "Element Dark" + this._themeMapping[themeDisplayName] = { + cssLocation, + id: themeId, + variables: variables, + }; + } + if (defaultDarkVariant.id && defaultLightVariant.id) { + /** + * As mentioned above, if there's both a default dark and a default light variant, + * add them to themeMapping separately. + */ + const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; + } + else { + /** + * If only one default variant is found (i.e only dark default or light default but not both), + * treat it like any other variant. + */ + const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; + } + } + + get themeMapping() { + return this._themeMapping; + } + + injectCSSVariables(variables: Record) { + const root = document.documentElement; + for (const [variable, value] of Object.entries(variables)) { + root.style.setProperty(`--${variable}`, value); + } + } + + removeCSSVariables(variables: string[]) { + const root = document.documentElement; + for (const variable of variables) { + root.style.removeProperty(`--${variable}`); + } + } + + deriveVariables(variables: Record, derivedVariables: string[], isDark: boolean) { + const aliases: any = {}; + const resolvedVariables: any = {}; + const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + for (const variable of derivedVariables) { + // If this is an alias, store it for processing later + const [alias, value] = variable.split("="); + if (value) { + aliases[alias] = value; + continue; + } + // Resolve derived variables + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, baseVariable, operation, argument] = matches; + const value = variables[baseVariable]; + const resolvedValue = derive(value, operation, argument, isDark); + resolvedVariables[variable] = resolvedValue; + } + } + for (const [alias, variable] of Object.entries(aliases) as any) { + resolvedVariables[alias] = variables[variable] ?? resolvedVariables[variable]; + } + return resolvedVariables; + } +} diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index 89430663..9806dd10 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -14,33 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type {ILogItem} from "../../logging/types.js"; +import type {ILogItem} from "../../logging/types"; import type {Platform} from "./Platform.js"; +import {ThemeBuilder} from "./ThemeBuilder"; type NormalVariant = { id: string; cssLocation: string; + variables?: any; }; type DefaultVariant = { - dark: { - id: string; - cssLocation: string; + dark: NormalVariant & { variantName: string; }; - light: { - id: string; - cssLocation: string; + light: NormalVariant & { variantName: string; }; - default: { - id: string; - cssLocation: string; + default: NormalVariant & { variantName: string; }; } -type ThemeInformation = NormalVariant | DefaultVariant; +export type ThemeInformation = NormalVariant | DefaultVariant; export enum ColorSchemePreference { Dark, @@ -50,18 +46,31 @@ export enum ColorSchemePreference { export class ThemeLoader { private _platform: Platform; private _themeMapping: Record; + private _themeBuilder: ThemeBuilder; constructor(platform: Platform) { this._platform = platform; } async init(manifestLocations: string[], log?: ILogItem): Promise { + const idToManifest = new Map(); await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { this._themeMapping = {}; const results = await Promise.all( manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) ); - results.forEach(({ body }, i) => this._populateThemeMap(body, manifestLocations[i], log)); + results.forEach(({ body }, i) => idToManifest.set(body.id, { manifest: body, location: manifestLocations[i] })); + this._themeBuilder = new ThemeBuilder(idToManifest, this.preferredColorScheme); + results.forEach(({ body }, i) => { + if (body.extends) { + this._themeBuilder.populateDerivedTheme(body); + } + else { + this._populateThemeMap(body, manifestLocations[i], log); + } + }); + Object.assign(this._themeMapping, this._themeBuilder.themeMapping); + console.log("derived theme mapping", this._themeBuilder.themeMapping); }); } @@ -144,18 +153,23 @@ export class ThemeLoader { setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) { this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => { - let cssLocation: string; + let cssLocation: string, variables: Record; let themeDetails = this._themeMapping[themeName]; if ("id" in themeDetails) { cssLocation = themeDetails.cssLocation; + variables = themeDetails.variables; } else { if (!themeVariant) { throw new Error("themeVariant is undefined!"); } cssLocation = themeDetails[themeVariant].cssLocation; + variables = themeDetails[themeVariant].variables; } this._platform.replaceStylesheet(cssLocation); + if (variables) { + this._themeBuilder.injectCSSVariables(variables); + } this._platform.settingsStorage.setString("theme-name", themeName); if (themeVariant) { this._platform.settingsStorage.setString("theme-variant", themeVariant); diff --git a/theme.json b/theme.json new file mode 100644 index 00000000..aee95368 --- /dev/null +++ b/theme.json @@ -0,0 +1,51 @@ +{ + "name": "Customer", + "extends": "element", + "id": "customer", + "values": { + "variants": { + "dark": { + "dark": true, + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", + "background-color-secondary": "#2D3239", + "text-color": "#fff", + "accent-color": "#F03F5B", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#61708b", + "link-color": "#238cf5" + } + }, + "light": { + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", + "background-color-secondary": "#2D3239", + "text-color": "#fff", + "accent-color": "#F03F5B", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#61708b", + "link-color": "#238cf5" + } + }, + "red": { + "name": "Red", + "variables": { + "background-color-primary": "#1F1F1F", + "background-color-secondary": "#2B243E", + "text-color": "#fff", + "accent-color": "#F23041", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#F23030", + "link-color": "#238cf5" + } + } + } + } +}