This commit is contained in:
RMidhunSuresh 2022-07-19 17:33:06 +05:30
parent e1ee258630
commit ecb3a66dfc
4 changed files with 203 additions and 147 deletions

View file

@ -15,37 +15,16 @@ limitations under the License.
*/ */
import type {ILogItem} from "../../../logging/types"; import type {ILogItem} from "../../../logging/types";
import type {ThemeManifest} from "../../types/theme";
import type {Platform} from "../Platform.js"; import type {Platform} from "../Platform.js";
import {ThemeBuilder} from "./ThemeBuilder"; import {RuntimeThemeParser} from "./parsers/RuntimeThemeParser";
import type {Variant, ThemeInformation} from "./parsers/types";
type NormalVariant = { import {ColorSchemePreference} from "./parsers/types";
id: string; import { BuiltThemeParser } from "./parsers/BuiltThemeParser";
cssLocation: string;
variables?: any;
};
type Variant = NormalVariant & {
variantName: string;
};
type DefaultVariant = {
dark: Variant;
light: Variant;
default: Variant;
}
export type ThemeInformation = NormalVariant | DefaultVariant;
export enum ColorSchemePreference {
Dark,
Light
};
export class ThemeLoader { export class ThemeLoader {
private _platform: Platform; private _platform: Platform;
private _themeMapping: Record<string, ThemeInformation>; private _themeMapping: Record<string, ThemeInformation>;
private _themeBuilder: ThemeBuilder; private _injectedVariables?: Record<string, string>;
constructor(platform: Platform) { constructor(platform: Platform) {
this._platform = platform; this._platform = platform;
@ -53,11 +32,11 @@ export class ThemeLoader {
async init(manifestLocations: string[], log?: ILogItem): Promise<void> { async init(manifestLocations: string[], log?: ILogItem): Promise<void> {
await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => {
this._themeMapping = {};
const results = await Promise.all( const results = await Promise.all(
manifestLocations.map(location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) manifestLocations.map(location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response())
); );
this._themeBuilder = new ThemeBuilder(this._platform, this.preferredColorScheme); const runtimeThemeParser = new RuntimeThemeParser(this._platform, this.preferredColorScheme);
const builtThemeParser = new BuiltThemeParser(this.preferredColorScheme);
const runtimeThemePromises: Promise<void>[] = []; const runtimeThemePromises: Promise<void>[] = [];
for (let i = 0; i < results.length; ++i) { for (let i = 0; i < results.length; ++i) {
const { body } = results[i]; const { body } = results[i];
@ -69,11 +48,11 @@ export class ThemeLoader {
} }
const {body: baseManifest} = results[indexOfBaseManifest]; const {body: baseManifest} = results[indexOfBaseManifest];
const baseManifestLocation = manifestLocations[indexOfBaseManifest]; const baseManifestLocation = manifestLocations[indexOfBaseManifest];
const promise = this._themeBuilder.populateDerivedTheme(body, baseManifest, baseManifestLocation, log); const promise = runtimeThemeParser.parse(body, baseManifest, baseManifestLocation, log);
runtimeThemePromises.push(promise); runtimeThemePromises.push(promise);
} }
else { else {
this._populateThemeMap(body, manifestLocations[i], log); builtThemeParser.parse(body, manifestLocations[i], log);
} }
} }
catch(e) { catch(e) {
@ -81,98 +60,14 @@ export class ThemeLoader {
} }
} }
await Promise.all(runtimeThemePromises); await Promise.all(runtimeThemePromises);
Object.assign(this._themeMapping, this._themeBuilder.themeMapping); this._themeMapping = { ...builtThemeParser.themeMapping, ...runtimeThemeParser.themeMapping };
// Add the default-theme as an additional option to the mapping Object.assign(this._themeMapping, builtThemeParser.themeMapping, runtimeThemeParser.themeMapping);
const defaultThemeId = this.getDefaultTheme(); this._addDefaultThemeToMapping(log);
if (defaultThemeId) {
const themeDetails = this._findThemeDetailsFromId(defaultThemeId);
if (themeDetails) {
this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.themeData.cssLocation! };
const variables = themeDetails.themeData.variables;
if (variables) {
this._themeMapping["Default"].variables = variables;
}
}
}
log.log({ l: "Default Theme", theme: defaultThemeId});
log.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); log.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" });
log.log({ l: "Result", themeMapping: this._themeMapping }); log.log({ l: "Result", themeMapping: this._themeMapping });
}); });
} }
private _populateThemeMap(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) {
log.wrap("ThemeLoader.populateThemeMap", () => {
/*
After build has finished, the source section of each theme manifest
contains `built-assets` which is a mapping from the theme-id to
cssLocation of theme
*/
const builtAssets: Record<string, string> = manifest.source?.["built-assets"];
const themeName = manifest.name;
if (!themeName) {
throw new Error(`Theme name not found in manifest at ${manifestLocation}`);
}
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
for (let [themeId, cssLocation] of Object.entries(builtAssets)) {
try {
/**
* This cssLocation is relative to the location of the manifest file.
* So we first need to resolve it relative to the root of this hydrogen instance.
*/
cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href;
}
catch {
continue;
}
const variant = themeId.match(/.+-(.+)/)?.[1];
const variantDetails = manifest.values?.variants[variant!];
if (!variantDetails) {
throw new Error(`Variant ${variant} is missing in manifest at ${manifestLocation}`);
}
const { name: variantName, default: isDefault, dark } = variantDetails;
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;
continue;
}
// Non-default variants are keyed in themeMapping with "theme_name variant_name"
// eg: "Element Dark"
this._themeMapping[themeDisplayName] = {
cssLocation,
id: themeId
};
}
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 };
}
});
}
setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) { setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) {
this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => { this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => {
let cssLocation: string, variables: Record<string, string>; let cssLocation: string, variables: Record<string, string>;
@ -191,10 +86,10 @@ export class ThemeLoader {
this._platform.replaceStylesheet(cssLocation); this._platform.replaceStylesheet(cssLocation);
if (variables) { if (variables) {
log?.log({l: "Derived Theme", variables}); log?.log({l: "Derived Theme", variables});
this._themeBuilder.injectCSSVariables(variables); this._injectCSSVariables(variables);
} }
else { else {
this._themeBuilder.removePreviousCSSVariables(); this._removePreviousCSSVariables();
} }
this._platform.settingsStorage.setString("theme-name", themeName); this._platform.settingsStorage.setString("theme-name", themeName);
if (themeVariant) { if (themeVariant) {
@ -206,6 +101,25 @@ export class ThemeLoader {
}); });
} }
private _injectCSSVariables(variables: Record<string, string>): void {
const root = document.documentElement;
for (const [variable, value] of Object.entries(variables)) {
root.style.setProperty(`--${variable}`, value);
}
this._injectedVariables = variables;
}
private _removePreviousCSSVariables(): void {
if (!this._injectedVariables) {
return;
}
const root = document.documentElement;
for (const variable of Object.keys(this._injectedVariables)) {
root.style.removeProperty(`--${variable}`);
}
this._injectedVariables = undefined;
}
/** Maps theme display name to theme information */ /** Maps theme display name to theme information */
get themeMapping(): Record<string, ThemeInformation> { get themeMapping(): Record<string, ThemeInformation> {
return this._themeMapping; return this._themeMapping;
@ -246,6 +160,23 @@ export class ThemeLoader {
} }
} }
private _addDefaultThemeToMapping(log: ILogItem) {
log.wrap("addDefaultThemeToMapping", l => {
const defaultThemeId = this.getDefaultTheme();
if (defaultThemeId) {
const themeDetails = this._findThemeDetailsFromId(defaultThemeId);
if (themeDetails) {
this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.themeData.cssLocation! };
const variables = themeDetails.themeData.variables;
if (variables) {
this._themeMapping["Default"].variables = variables;
}
}
}
l.log({ l: "Default Theme", theme: defaultThemeId});
});
}
get preferredColorScheme(): ColorSchemePreference | undefined { get preferredColorScheme(): ColorSchemePreference | undefined {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) { if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return ColorSchemePreference.Dark; return ColorSchemePreference.Dark;

View file

@ -0,0 +1,106 @@
/*
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 "./types";
import type {ThemeManifest} from "../../../types/theme";
import type {ILogItem} from "../../../../logging/types";
import {ColorSchemePreference} from "./types";
export class BuiltThemeParser {
private _themeMapping: Record<string, ThemeInformation> = {};
private _preferredColorScheme?: ColorSchemePreference;
constructor(preferredColorScheme?: ColorSchemePreference) {
this._preferredColorScheme = preferredColorScheme;
}
parse(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) {
log.wrap("BuiltThemeParser.parse", () => {
/*
After build has finished, the source section of each theme manifest
contains `built-assets` which is a mapping from the theme-id to
cssLocation of theme
*/
const builtAssets: Record<string, string> = manifest.source?.["built-assets"];
const themeName = manifest.name;
if (!themeName) {
throw new Error(`Theme name not found in manifest at ${manifestLocation}`);
}
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
for (let [themeId, cssLocation] of Object.entries(builtAssets)) {
try {
/**
* This cssLocation is relative to the location of the manifest file.
* So we first need to resolve it relative to the root of this hydrogen instance.
*/
cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href;
}
catch {
continue;
}
const variant = themeId.match(/.+-(.+)/)?.[1];
const variantDetails = manifest.values?.variants[variant!];
if (!variantDetails) {
throw new Error(`Variant ${variant} is missing in manifest at ${manifestLocation}`);
}
const { name: variantName, default: isDefault, dark } = variantDetails;
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;
continue;
}
// Non-default variants are keyed in themeMapping with "theme_name variant_name"
// eg: "Element Dark"
this._themeMapping[themeDisplayName] = {
cssLocation,
id: themeId
};
}
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(): Record<string, ThemeInformation> {
return this._themeMapping;
}
}

View file

@ -13,26 +13,25 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type {ThemeInformation} from "./ThemeLoader"; import type {ThemeInformation} from "./types";
import type {Platform} from "../Platform.js"; import type {Platform} from "../../Platform.js";
import type {ThemeManifest} from "../../types/theme"; import type {ThemeManifest} from "../../../types/theme";
import {ColorSchemePreference} from "./ThemeLoader"; import {ColorSchemePreference} from "./types";
import {IconColorizer} from "./IconColorizer"; import {IconColorizer} from "../IconColorizer";
import {DerivedVariables} from "./DerivedVariables"; import {DerivedVariables} from "../DerivedVariables";
import {ILogItem} from "../../../logging/types"; import {ILogItem} from "../../../../logging/types";
export class ThemeBuilder { export class RuntimeThemeParser {
private _themeMapping: Record<string, ThemeInformation> = {}; private _themeMapping: Record<string, ThemeInformation> = {};
private _preferredColorScheme?: ColorSchemePreference; private _preferredColorScheme?: ColorSchemePreference;
private _platform: Platform; private _platform: Platform;
private _injectedVariables?: Record<string, string>;
constructor(platform: Platform, preferredColorScheme?: ColorSchemePreference) { constructor(platform: Platform, preferredColorScheme?: ColorSchemePreference) {
this._preferredColorScheme = preferredColorScheme; this._preferredColorScheme = preferredColorScheme;
this._platform = platform; this._platform = platform;
} }
async populateDerivedTheme(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise<void> { async parse(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise<void> {
await log.wrap("ThemeBuilder.populateThemeMap", async () => { await log.wrap("ThemeBuilder.populateThemeMap", async () => {
const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, baseManifestLocation, log); const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, baseManifestLocation, log);
const themeName = manifest.name; const themeName = manifest.name;
@ -96,22 +95,4 @@ export class ThemeBuilder {
return this._themeMapping; return this._themeMapping;
} }
injectCSSVariables(variables: Record<string, string>): void {
const root = document.documentElement;
for (const [variable, value] of Object.entries(variables)) {
root.style.setProperty(`--${variable}`, value);
}
this._injectedVariables = variables;
}
removePreviousCSSVariables(): void {
if (!this._injectedVariables) {
return;
}
const root = document.documentElement;
for (const variable of Object.keys(this._injectedVariables)) {
root.style.removeProperty(`--${variable}`);
}
this._injectedVariables = undefined;
}
} }

View file

@ -0,0 +1,38 @@
/*
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.
*/
export type NormalVariant = {
id: string;
cssLocation: string;
variables?: any;
};
export type Variant = NormalVariant & {
variantName: string;
};
export type DefaultVariant = {
dark: Variant;
light: Variant;
default: Variant;
}
export type ThemeInformation = NormalVariant | DefaultVariant;
export enum ColorSchemePreference {
Dark,
Light
};