forked from mystiq/hydrogen-web
WIP
This commit is contained in:
parent
d731eab51c
commit
ac7be0c7a1
4 changed files with 174 additions and 72 deletions
54
src/platform/web/DerivedVariables.ts
Normal file
54
src/platform/web/DerivedVariables.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
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 {derive} from "../../../scripts/postcss/color.mjs";
|
||||||
|
|
||||||
|
export class DerivedVariables {
|
||||||
|
private _baseVariables: Record<string, string>;
|
||||||
|
private _variablesToDerive: string[]
|
||||||
|
private _isDark: boolean
|
||||||
|
|
||||||
|
constructor(baseVariables: Record<string, string>, variablesToDerive: string[], isDark: boolean) {
|
||||||
|
this._baseVariables = baseVariables;
|
||||||
|
this._variablesToDerive = variablesToDerive;
|
||||||
|
this._isDark = isDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
toVariables(): Record<string, string> {
|
||||||
|
const aliases: any = {};
|
||||||
|
const resolvedVariables: any = {};
|
||||||
|
const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/;
|
||||||
|
for (const variable of this._variablesToDerive) {
|
||||||
|
// 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 = this._baseVariables[baseVariable];
|
||||||
|
const resolvedValue = derive(value, operation, argument, this._isDark);
|
||||||
|
resolvedVariables[variable] = resolvedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [alias, variable] of Object.entries(aliases) as any) {
|
||||||
|
resolvedVariables[alias] = this._baseVariables[variable] ?? resolvedVariables[variable];
|
||||||
|
}
|
||||||
|
return resolvedVariables;
|
||||||
|
}
|
||||||
|
}
|
79
src/platform/web/IconColorizer.ts
Normal file
79
src/platform/web/IconColorizer.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
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 {Platform} from "./Platform.js";
|
||||||
|
import {getColoredSvgString} from "../../../scripts/postcss/svg-colorizer.js";
|
||||||
|
|
||||||
|
type ParsedStructure = {
|
||||||
|
[variableName: string]: {
|
||||||
|
svg: Promise<{ status: number; body: string }>;
|
||||||
|
primary: string | null;
|
||||||
|
secondary: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class IconColorizer {
|
||||||
|
private _iconVariables: Record<string, string>;
|
||||||
|
private _resolvedVariables: Record<string, string>;
|
||||||
|
private _manifestLocation: string;
|
||||||
|
private _platform: Platform;
|
||||||
|
|
||||||
|
constructor(platform: Platform, iconVariables: Record<string, string>, resolvedVariables: Record<string, string>, manifestLocation: string) {
|
||||||
|
this._platform = platform;
|
||||||
|
this._iconVariables = iconVariables;
|
||||||
|
this._resolvedVariables = resolvedVariables;
|
||||||
|
this._manifestLocation = manifestLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toVariables(): Promise<Record<string, string>> {
|
||||||
|
const { parsedStructure, promises } = await this._fetchAndParseIcons();
|
||||||
|
await Promise.all(promises);
|
||||||
|
return this._produceColoredIconVariables(parsedStructure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _fetchAndParseIcons(): Promise<{ parsedStructure: ParsedStructure, promises: any[] }> {
|
||||||
|
const promises: any[] = [];
|
||||||
|
const parsedStructure: ParsedStructure = {};
|
||||||
|
for (const [variable, url] of Object.entries(this._iconVariables)) {
|
||||||
|
const urlObject = new URL(`https://${url}`);
|
||||||
|
const pathWithoutQueryParams = urlObject.hostname;
|
||||||
|
const relativePath = new URL(pathWithoutQueryParams, new URL(this._manifestLocation, window.location.origin));
|
||||||
|
const responsePromise = this._platform.request(relativePath, { method: "GET", format: "text", cache: true, }).response()
|
||||||
|
promises.push(responsePromise);
|
||||||
|
const searchParams = urlObject.searchParams;
|
||||||
|
parsedStructure[variable] = {
|
||||||
|
svg: responsePromise,
|
||||||
|
primary: searchParams.get("primary"),
|
||||||
|
secondary: searchParams.get("secondary")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { parsedStructure, promises };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _produceColoredIconVariables(parsedStructure: ParsedStructure): Promise<Record<string, string>> {
|
||||||
|
let coloredVariables: Record<string, string> = {};
|
||||||
|
for (const [variable, { svg, primary, secondary }] of Object.entries(parsedStructure)) {
|
||||||
|
const { body: svgCode } = await svg;
|
||||||
|
if (!primary) {
|
||||||
|
throw new Error(`Primary color variable ${primary} not in list of variables!`);
|
||||||
|
}
|
||||||
|
const primaryColor = this._resolvedVariables[primary], secondaryColor = this._resolvedVariables[secondary!];
|
||||||
|
const coloredSvgCode = getColoredSvgString(svgCode, primaryColor, secondaryColor);
|
||||||
|
const dataURI = `url('data:image/svg+xml;utf8,${encodeURIComponent(coloredSvgCode)}')`;
|
||||||
|
coloredVariables[variable] = dataURI;
|
||||||
|
}
|
||||||
|
return coloredVariables;
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,72 +14,59 @@ 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 "./ThemeLoader";
|
||||||
|
import type {Platform} from "./Platform.js";
|
||||||
import {ColorSchemePreference} from "./ThemeLoader";
|
import {ColorSchemePreference} from "./ThemeLoader";
|
||||||
import {derive} from "../../../scripts/postcss/color.mjs";
|
import {IconColorizer} from "./IconColorizer";
|
||||||
|
import {DerivedVariables} from "./DerivedVariables";
|
||||||
|
|
||||||
export class ThemeBuilder {
|
export class ThemeBuilder {
|
||||||
// todo: replace any with manifest type when PR is merged
|
// todo: replace any with manifest type when PR is merged
|
||||||
private _idToManifest: Map<string, any>;
|
private _idToManifest: Map<string, any>;
|
||||||
private _themeMapping: Record<string, ThemeInformation> = {};
|
private _themeMapping: Record<string, ThemeInformation> = {};
|
||||||
private _themeToVariables: Record<string, any> = {};
|
|
||||||
private _preferredColorScheme?: ColorSchemePreference;
|
private _preferredColorScheme?: ColorSchemePreference;
|
||||||
|
private _platform: Platform;
|
||||||
|
private _injectedVariables?: Record<string, string>;
|
||||||
|
|
||||||
constructor(manifestMap: Map<string, any>, preferredColorScheme?: ColorSchemePreference) {
|
constructor(platform: Platform, manifestMap: Map<string, any>, preferredColorScheme?: ColorSchemePreference) {
|
||||||
this._idToManifest = manifestMap;
|
this._idToManifest = manifestMap;
|
||||||
this._preferredColorScheme = preferredColorScheme;
|
this._preferredColorScheme = preferredColorScheme;
|
||||||
|
this._platform = platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
populateDerivedTheme(manifest) {
|
async populateDerivedTheme(manifest) {
|
||||||
const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends);
|
const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends);
|
||||||
const runtimeCssLocation = baseManifest.source?.["runtime-asset"];
|
const runtimeCssLocation = baseManifest.source?.["runtime-asset"];
|
||||||
const cssLocation = new URL(runtimeCssLocation, new URL(location, window.location.origin)).href;
|
const cssLocation = new URL(runtimeCssLocation, new URL(location, window.location.origin)).href;
|
||||||
const derivedVariables = baseManifest.source?.["derived-variables"];
|
const derivedVariables = baseManifest.source?.["derived-variables"];
|
||||||
|
const icons = baseManifest.source?.["icon"];
|
||||||
const themeName = manifest.name;
|
const themeName = manifest.name;
|
||||||
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
|
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
|
||||||
for (const [variant, variantDetails] of Object.entries(manifest.values.variants) as [string, any][]) {
|
for (const [variant, variantDetails] of Object.entries(manifest.values.variants) as [string, any][]) {
|
||||||
|
try {
|
||||||
const themeId = `${manifest.id}-${variant}`;
|
const themeId = `${manifest.id}-${variant}`;
|
||||||
const { name: variantName, default: isDefault, dark, variables } = variantDetails;
|
const { name: variantName, default: isDefault, dark, variables } = variantDetails;
|
||||||
const resolvedVariables = this.deriveVariables(variables, derivedVariables, dark);
|
const resolvedVariables = new DerivedVariables(variables, derivedVariables, dark).toVariables();
|
||||||
Object.assign(variables, resolvedVariables);
|
Object.assign(variables, resolvedVariables);
|
||||||
|
const iconVariables = await new IconColorizer(this._platform, icons, variables, location).toVariables();
|
||||||
|
Object.assign(variables, resolvedVariables, iconVariables);
|
||||||
const themeDisplayName = `${themeName} ${variantName}`;
|
const themeDisplayName = `${themeName} ${variantName}`;
|
||||||
if (isDefault) {
|
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;
|
const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant;
|
||||||
defaultVariant.variantName = variantName;
|
Object.assign(defaultVariant, { variantName, id: themeId, cssLocation, variables });
|
||||||
defaultVariant.id = themeId;
|
continue;
|
||||||
defaultVariant.cssLocation = cssLocation;
|
}
|
||||||
defaultVariant.variables = variables;
|
this._themeMapping[themeDisplayName] = { cssLocation, id: themeId, variables: variables, };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
continue;
|
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) {
|
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;
|
const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant;
|
||||||
this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant };
|
this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant };
|
||||||
}
|
}
|
||||||
else {
|
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;
|
const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant;
|
||||||
this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation };
|
this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation };
|
||||||
}
|
}
|
||||||
|
@ -94,38 +81,17 @@ export class ThemeBuilder {
|
||||||
for (const [variable, value] of Object.entries(variables)) {
|
for (const [variable, value] of Object.entries(variables)) {
|
||||||
root.style.setProperty(`--${variable}`, value);
|
root.style.setProperty(`--${variable}`, value);
|
||||||
}
|
}
|
||||||
|
this._injectedVariables = variables;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeCSSVariables(variables: string[]) {
|
removePreviousCSSVariables() {
|
||||||
|
if (!this._injectedVariables) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
for (const variable of variables) {
|
for (const variable of Object.keys(this._injectedVariables)) {
|
||||||
root.style.removeProperty(`--${variable}`);
|
root.style.removeProperty(`--${variable}`);
|
||||||
}
|
}
|
||||||
}
|
this._injectedVariables = undefined;
|
||||||
|
|
||||||
deriveVariables(variables: Record<string, string>, 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,17 +60,17 @@ export class ThemeLoader {
|
||||||
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())
|
||||||
);
|
);
|
||||||
results.forEach(({ body }, i) => idToManifest.set(body.id, { manifest: body, location: manifestLocations[i] }));
|
results.forEach(({ body }, i) => idToManifest.set(body.id, { manifest: body, location: manifestLocations[i] }));
|
||||||
this._themeBuilder = new ThemeBuilder(idToManifest, this.preferredColorScheme);
|
this._themeBuilder = new ThemeBuilder(this._platform, idToManifest, this.preferredColorScheme);
|
||||||
results.forEach(({ body }, i) => {
|
for (let i = 0; i < results.length; ++i) {
|
||||||
|
const { body } = results[i];
|
||||||
if (body.extends) {
|
if (body.extends) {
|
||||||
this._themeBuilder.populateDerivedTheme(body);
|
await this._themeBuilder.populateDerivedTheme(body);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this._populateThemeMap(body, manifestLocations[i], log);
|
this._populateThemeMap(body, manifestLocations[i], log);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
Object.assign(this._themeMapping, this._themeBuilder.themeMapping);
|
Object.assign(this._themeMapping, this._themeBuilder.themeMapping);
|
||||||
console.log("derived theme mapping", this._themeBuilder.themeMapping);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,6 +170,9 @@ export class ThemeLoader {
|
||||||
if (variables) {
|
if (variables) {
|
||||||
this._themeBuilder.injectCSSVariables(variables);
|
this._themeBuilder.injectCSSVariables(variables);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
this._themeBuilder.removePreviousCSSVariables();
|
||||||
|
}
|
||||||
this._platform.settingsStorage.setString("theme-name", themeName);
|
this._platform.settingsStorage.setString("theme-name", themeName);
|
||||||
if (themeVariant) {
|
if (themeVariant) {
|
||||||
this._platform.settingsStorage.setString("theme-variant", themeVariant);
|
this._platform.settingsStorage.setString("theme-variant", themeVariant);
|
||||||
|
|
Loading…
Reference in a new issue