Skip to content

Commit

Permalink
feat(chrome-ext): palette generator
Browse files Browse the repository at this point in the history
  • Loading branch information
matthieu-crouzet committed Apr 15, 2024
1 parent 77b79c5 commit 8171117
Show file tree
Hide file tree
Showing 14 changed files with 441 additions and 88 deletions.
3 changes: 2 additions & 1 deletion apps/chrome-devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@stylistic/eslint-plugin-ts": "^1.5.4",
"@types/chrome": "^0.0.266",
"@types/jest": "~29.5.2",
"@types/tinycolor2": "^1.4.6",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@typescript-eslint/types": "^7.2.0",
Expand Down Expand Up @@ -102,8 +103,8 @@
"ag-grid-angular": "~31.1.0",
"ag-grid-community": "~31.1.0",
"bootstrap": "5.3.3",
"color": "^4.2.3",
"rxjs": "^7.8.1",
"tinycolor2": "^1.6.0",
"tslib": "^2.6.2",
"zone.js": "~0.14.2"
}
Expand Down
14 changes: 12 additions & 2 deletions apps/chrome-devtools/src/app-devtools/theming-panel/color.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Pipe, type PipeTransform } from '@angular/core';
import color from 'color';
import TinyColor from 'tinycolor2';

@Pipe({
name: 'color',
Expand All @@ -8,9 +8,19 @@ import color from 'color';
export class ColorPipe implements PipeTransform {
public transform(variableValue: string) {
try {
return color(variableValue).hex();
return new TinyColor(variableValue).toHexString();
} catch {
return null;
}
}
}

@Pipe({
name: 'contrast',
standalone: true
})
export class ConstrastPipe implements PipeTransform {
public transform(color: string) {
return new TinyColor(color).isLight() ? 'black' : 'white';
}
}
120 changes: 120 additions & 0 deletions apps/chrome-devtools/src/app-devtools/theming-panel/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StylingVariable } from '@o3r/styling';
import TinyColor from 'tinycolor2';

/** RegExp to find a variable and get the variable name in the first group */
export const varRegExp = /^var\(--([^, )]*).*\)$/;
Expand Down Expand Up @@ -41,3 +42,122 @@ export const resolveVariable = (
}
return variableValue;
};

type Variant =
| '50'
| '100'
| '200'
| '300'
| '400'
| '500'
| '600'
| '700'
| '800'
| '900'
| 'A100'
| 'A200'
| 'A400'
| 'A700';

const VARIANTS: Variant[] = [
'50',
'100',
'200',
'300',
'400',
'500',
'600',
'700',
'800',
'900',
'A100',
'A200',
'A400',
'A700'
];

export const DEFAULT_VARIANT: Variant = '500';

/* eslint-disable @typescript-eslint/naming-convention */
const SATURATION_VALUES: Record<Variant, number> = {
'50': 0.91,
'100': 0.98,
'200': 0.96,
'300': 0.95,
'400': 0.96,
'500': 1,
'600': 1,
'700': 0.99,
'800': 0.89,
'900': 0.86,
'A100': 0.89,
'A200': 0.98,
'A400': 0.97,
'A700': 0.9
};

const LIGHTNESS_VALUES: Record<Variant, number> = {
'50': 0.12,
'100': 0.3,
'200': 0.5,
'300': 0.7,
'400': 0.86,
'500': 1,
'600': 0.87,
'700': 0.66,
'800': 0.45,
'900': 0.16,
'A100': 0.76,
'A200': 0.64,
'A400': 0.49,
'A700': 0.44
};

const HUE_VALUES: Record<Variant, number> = {
'50': 1,
'100': 1,
'200': 1,
'300': 1,
'400': 1,
'500': 1,
'600': 1,
'700': 1,
'800': 1,
'900': 1,
'A100': 1,
'A200': 1,
'A400': 1,
'A700': 1.01
};
/* eslint-enable @typescript-eslint/naming-convention */

/**
* Returns palette colors from one color
* @param colorHex
*/
export const getPaletteColors = (colorHex: string): Record<string, string> => {
const colorHsl = new TinyColor(colorHex).toHsl();
return VARIANTS.reduce((acc: Record<string, string>, variant: Variant) => {
const h = (colorHsl.h || 360) * HUE_VALUES[variant];
let s = colorHsl.s;
let l = colorHsl.l * 100;
if (variant.startsWith('A')) {
s = SATURATION_VALUES[variant];
l = LIGHTNESS_VALUES[variant] * 100;
} else if (+variant < +DEFAULT_VARIANT) {
s = colorHsl.s * SATURATION_VALUES[variant];
l = (colorHsl.l * 100 - 100) * LIGHTNESS_VALUES[variant] + 100;
} else if (+variant > +DEFAULT_VARIANT) {
s = (1 - SATURATION_VALUES[variant]) + SATURATION_VALUES[variant] * colorHsl.s;
l = (1 - LIGHTNESS_VALUES[variant]) * colorHsl.l * colorHsl.l * 100 + LIGHTNESS_VALUES[variant] * colorHsl.l * 100;
}
acc[variant] = new TinyColor(`hsl(${h}, ${s * 100}%, ${l}%)`).toHexString();
return acc;
}, {});
};

/**
* Get variant name from a variable name
* @param variableName
*/
export const getVariant = (variableName: string) => variableName.split('-').pop();
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,30 @@ import { ChangeDetectionStrategy, Component, computed, effect, type OnDestroy, t
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { DfTooltipModule } from '@design-factory/design-factory';
import { NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { type GetStylingVariableContentMessage, type StylingVariable, THEME_TAG_NAME } from '@o3r/styling';
import { NgbAccordionModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { computeItemIdentifier } from '@o3r/core';
import { type GetStylingVariableContentMessage, PALETTE_TAG_NAME, type StylingVariable, THEME_TAG_NAME } from '@o3r/styling';
import { combineLatest, Observable, Subscription } from 'rxjs';
import { filter, map, shareReplay, startWith, throttleTime } from 'rxjs/operators';
import { ChromeExtensionConnectionService } from '../../services/connection.service';
import { ColorPipe } from './color.pipe';
import { resolveVariable, searchFn } from './common';
import { ColorPipe, ConstrastPipe } from './color.pipe';
import { DEFAULT_VARIANT, getPaletteColors, getVariant, resolveVariable, searchFn } from './common';
import { IsRefPipe } from './is-ref.pipe';
import { MemoizePipe } from './memoize.pipe';
import { VariableLabelPipe } from './variable-label.pipe';
import { VariableNamePipe } from './variable-name.pipe';

const THROTTLE_TIME = 100;

type VariableGroupType = 'type' | 'category' | 'component';

interface VariableGroup {
isPalette: boolean;
name: string;
variables: StylingVariable[];
defaultVariable?: StylingVariable;
}

@Component({
selector: 'o3r-theming-panel-pres',
templateUrl: './theming-panel-pres.template.html',
Expand All @@ -24,9 +34,11 @@ const THROTTLE_TIME = 100;
standalone: true,
imports: [
IsRefPipe,
NgbAccordionModule,
ReactiveFormsModule,
FormsModule,
ColorPipe,
ConstrastPipe,
NgbTypeaheadModule,
VariableLabelPipe,
DfTooltipModule,
Expand All @@ -38,11 +50,13 @@ export class ThemingPanelPresComponent implements OnDestroy {
public readonly resolvedVariables: Signal<Record<string, string>>;
public readonly variablesMap: Signal<Record<string, StylingVariable>>;
public readonly numberOfVariables: Signal<number>;
public readonly themeVariables: Signal<StylingVariable[]>;
public readonly filteredThemeVariables: Signal<StylingVariable[]>;
public readonly variables: Signal<StylingVariable[]>;
public readonly groupedVariables: Signal<VariableGroup[]>;
public readonly form = new FormGroup({
variables: new FormGroup<Record<string, FormControl<string | null>>>({}),
search: new FormControl('')
search: new FormControl(''),
themeOnly: new FormControl(true),
groupBy: new FormControl<VariableGroupType>('category')
});

private readonly variables$: Observable<StylingVariable[]>;
Expand All @@ -59,31 +73,81 @@ export class ThemingPanelPresComponent implements OnDestroy {
startWith([]),
shareReplay({ refCount: true, bufferSize: 1 })
);
const variables = toSignal(this.variables$, { initialValue: [] });
this.variablesMap = computed(() => variables().reduce((acc: Record<string, StylingVariable>, curr) => {
this.variables = toSignal(this.variables$, { initialValue: [] });
this.variablesMap = computed(() => this.variables().reduce((acc: Record<string, StylingVariable>, curr) => {
acc[curr.name] = curr;
return acc;
}, {}));
this.resolvedVariables = computed(() => variables().reduce((acc: Record<string, string>, variable, _, array) => {
this.resolvedVariables = computed(() => this.variables().reduce((acc: Record<string, string>, variable, _, array) => {
acc[variable.name] = resolveVariable(variable.name, this.runtimeValues(), array) || '';
return acc;
}, {}));
this.numberOfVariables = computed(() => Object.keys(this.resolvedVariables()).length);
this.themeVariables = computed(() => variables().filter((variable) => variable.tags?.includes(THEME_TAG_NAME)));

const search = toSignal(this.form.controls.search.valueChanges.pipe(
map((value) => (value || '').toLowerCase()),
throttleTime(THROTTLE_TIME, undefined, { trailing: true })
), { initialValue: '' });

this.filteredThemeVariables = computed(() => {
const onlyTheme = toSignal(this.form.controls.themeOnly.valueChanges, { initialValue: true });

const filteredVariables = computed(() => {
const searchText = search();
return searchText ? this.themeVariables().filter((variable) => searchFn(variable, searchText)) : this.themeVariables();
const vars = onlyTheme()
? this.variables()
.filter((variable) => variable.tags?.includes(THEME_TAG_NAME) || variable.tags?.includes(PALETTE_TAG_NAME))
: this.variables();
return searchText ? vars.filter((variable) => searchFn(variable, searchText)) : vars;
});

const groupBy = toSignal(this.form.controls.groupBy.valueChanges, { initialValue: 'category' });

this.groupedVariables = computed(() => {
const group = groupBy() || 'category';
const vars = filteredVariables();
return Object.entries(
vars.reduce((acc: Record<string, StylingVariable[]>, curr: StylingVariable) => {
const varGroup = (
group === 'component'
? curr.component && computeItemIdentifier(curr.component.name, curr.component.library)
: curr[group]
) || 'others';
acc[varGroup] ||= [];
acc[varGroup].push(curr);
return acc;
}, {})
).reduce((acc: VariableGroup[], [name, variables]) => {
const isPalette = variables.every((variable) => variable.tags?.includes(PALETTE_TAG_NAME));
const defaultVariable = variables.find((variable) => getVariant(variable.name) === DEFAULT_VARIANT);
return acc.concat([{
name,
variables,
isPalette,
defaultVariable
}]);
}, []).sort((a, b) => {
// Others should go at the end
if (a.name === 'others') {
return 1;
}
if (b.name === 'others') {
return -1;
}
// Palette should come first
if (a.isPalette && !b.isPalette) {
return -1;
}
if (!a.isPalette && b.isPalette) {
return 1;
}
// Alphabetical order
return a.name > b.name ? 1 : -1;
});
});

effect(() => {
const variablesControl = this.form.controls.variables;
this.themeVariables().forEach((variable) => {
this.variables().forEach((variable) => {
const value = variable.runtimeValue ?? variable.defaultValue;
const control = variablesControl.controls[variable.name];
if (!control) {
Expand Down Expand Up @@ -114,10 +178,18 @@ export class ThemingPanelPresComponent implements OnDestroy {
);
}

private changeColor(variableName: string, value: string) {
this.form.controls.variables.controls[variableName].setValue(value);
}

public ngOnDestroy() {
this.subscription.unsubscribe();
}

/**
* Typeahead search function
* @param currentVariable
*/
public variableSearch = (currentVariable: StylingVariable) => (text$: Observable<string>): Observable<string[]> => combineLatest([
text$, this.variables$, this.runtimeValues$
]).pipe(
Expand All @@ -133,11 +205,49 @@ export class ThemingPanelPresComponent implements OnDestroy {
: [])
);

/**
* Handler for color picker change
* @param variableName
* @param event
*/
public onColorChange(variableName: string, event: UIEvent) {
this.form.controls.variables.controls[variableName].setValue((event.target as HTMLInputElement).value);
this.changeColor(variableName, (event.target as HTMLInputElement).value);
}

/**
* Handler for color reset
* @param variable
*/
public onColorReset(variable: StylingVariable) {
this.form.controls.variables.controls[variable.name].setValue(variable.defaultValue);
this.changeColor(variable.name, variable.defaultValue);
}

/**
* Handler for palette color change
* @param palette
* @param event
*/
public onPaletteChange(group: VariableGroup, event: UIEvent) {
const baseColor = (event.target as HTMLInputElement).value;
const palette = getPaletteColors(baseColor);
group.variables.forEach((variable) => {
const variant = getVariant(variable.name);
const color = variant ? palette[variant] : undefined;
if (color) {
this.changeColor(variable.name, color);
}
});
}

/**
* Handler for palette color reset
* @param palette
* @param event
*/
public onPaletteReset(palette: VariableGroup, event: UIEvent) {
// Needed to not open or close the accordion
event.preventDefault();
event.stopPropagation();
palette.variables.forEach((variable) => this.changeColor(variable.name, variable.defaultValue));
}
}
Loading

0 comments on commit 8171117

Please sign in to comment.