- @for (localization of filteredLocalizations(); track localization.key) {
-
-
-
-
-
-
-
-
-
+ @if (hasLocalizations()) {
+
+
+ @for (localization of filteredLocalizations(); track localization.key) {
+
+
+
+
+
-
- } @empty {
- No localization found for your search.
- }
-
-
-} @else {
-
No metadata provided for localization.
-
- To provide metadata you can read the following
-
- documentation
-
-
-}
+ } @empty {
+
No localization found for your search.
+ }
+ @if (isTruncated()) {
+
Too many matches for this filter, please be more specific in your search.
+ }
+
+
+ } @else {
+
No metadata provided for localization.
+
+ To provide metadata you can read the following
+
+ documentation
+
+
+ }
+
diff --git a/apps/chrome-devtools/src/app-devtools/state-panel/state-panel.component.ts b/apps/chrome-devtools/src/app-devtools/state-panel/state-panel.component.ts
new file mode 100644
index 0000000000..822c7e1410
--- /dev/null
+++ b/apps/chrome-devtools/src/app-devtools/state-panel/state-panel.component.ts
@@ -0,0 +1,300 @@
+import { DOCUMENT, JsonPipe, KeyValuePipe, NgClass } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ effect,
+ type ElementRef,
+ inject,
+ signal,
+ type Signal,
+ untracked,
+ viewChild,
+ ViewEncapsulation, WritableSignal
+} from '@angular/core';
+import { type AbstractControl, FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, type ValidationErrors, type ValidatorFn, Validators } from '@angular/forms';
+import { DfTooltipModule, DfTriggerClickDirective } from '@design-factory/design-factory';
+import { StateService } from '../../services';
+import { getBestColorContrast } from '../theming-panel/color.helpers';
+import type { State } from '../../extension/interface';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { combineLatest, map } from 'rxjs';
+
+type StateForm = {
+ name: FormControl
;
+ color: FormControl;
+};
+
+type StatesPanelForm = {
+ newStateName: FormControl;
+ newStateColor: FormControl;
+ states: FormGroup>>;
+};
+
+const duplicateNameValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
+ const mainForm = (control.parent?.parent?.parent ?? control.parent) as FormGroup | null | undefined;
+ const names = new Set();
+ const controls = Object.values(mainForm?.controls.states.controls || {}).map((ctrl) => ctrl.controls.name);
+ for (const ctrl of controls) {
+ if (ctrl !== control && control.value === ctrl.value && names.has(control.value)) {
+ return {
+ duplicateName: `${control.value} is duplicated`
+ };
+ }
+ if (ctrl.value) {
+ names.add(ctrl.value);
+ }
+ }
+ return null;
+};
+
+const stateNameValidators = [Validators.required, duplicateNameValidator];
+
+const createStateForm = (name: string, color?: string | null) => new FormGroup({
+ name: new FormControl(name, stateNameValidators),
+ color: new FormControl(color)
+});
+
+@Component({
+ selector: 'o3r-state-panel',
+ templateUrl: './state-panel.template.html',
+ styles: `
+ .list-group-item > i {
+ cursor: pointer;
+ }
+ .list-group-item > i.disabled {
+ cursor: not-allowed;
+ }
+ .import-state-text:empty {
+ margin: 0 !important;
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ standalone: true,
+ imports: [
+ KeyValuePipe,
+ JsonPipe,
+ ReactiveFormsModule,
+ FormsModule,
+ DfTriggerClickDirective,
+ DfTooltipModule,
+ NgClass
+ ]
+})
+export class StatePanelComponent {
+ private readonly document = inject(DOCUMENT);
+ private readonly stateService = inject(StateService);
+ private readonly formBuilder = inject(FormBuilder);
+
+ public readonly importStateText = viewChild>('importStateText');
+ public readonly states = this.stateService.states;
+ public readonly activeState = this.stateService.activeState;
+ public readonly localState = this.stateService.localState;
+ public readonly hasLocalChanges = this.stateService.hasLocalChanges;
+ public readonly newStateNameErrorMessage: Signal;
+ public readonly downloadState: WritableSignal<{ text: string; success: boolean } | null> = signal<{ text: string; success: boolean } | null>(null);
+ public readonly form = this.formBuilder.group({
+ newStateName: new FormControl('', stateNameValidators),
+ newStateColor: new FormControl(''),
+ states: this.formBuilder.group(
+ Object.values(this.states()).reduce((acc: Record>, { name, color }) => {
+ acc[name] = createStateForm(name, color);
+ return acc;
+ }, {})
+ )
+ });
+
+ constructor() {
+ effect(() => {
+ const states = this.states();
+ const statesControl = this.form.controls.states;
+ Object.values(states).forEach((state) => {
+ if (!statesControl.controls[state.name]) {
+ const control = createStateForm(state.name, state.color);
+ statesControl.addControl(state.name, control);
+ if (untracked(this.activeState)?.name !== state.name) {
+ control.disable();
+ }
+ }
+ });
+ });
+ effect(() => {
+ const activeState = this.activeState();
+ Object.entries(this.form.controls.states.controls).forEach(([key, control]) => {
+ if (activeState?.name === key) {
+ control.enable();
+ } else {
+ control.disable();
+ }
+ });
+ });
+ const newStateNameControl = this.form.controls.newStateName;
+ effect(() => {
+ if (this.hasLocalChanges()) {
+ this.form.controls.newStateColor.enable();
+ newStateNameControl.enable();
+ } else {
+ this.form.controls.newStateColor.disable();
+ newStateNameControl.disable();
+ }
+ });
+ this.newStateNameErrorMessage = toSignal(
+ combineLatest([
+ newStateNameControl.valueChanges,
+ newStateNameControl.statusChanges
+ ]).pipe(
+ map(() => newStateNameControl.errors
+ ? newStateNameControl.errors.required
+ ? 'Please provide a state name.'
+ : 'Please provide a unique name.'
+ : null
+ )
+ ),
+ { initialValue: null }
+ );
+ }
+
+ private saveState(oldStateName: string, stateName: string, stateColor: string) {
+ this.stateService.updateState(
+ oldStateName,
+ {
+ ...this.localState(),
+ name: stateName,
+ color: stateColor,
+ colorContrast: getBestColorContrast(stateColor)
+ }
+ );
+ this.stateService.setActiveState(stateName);
+ }
+
+ /**
+ * Update a state and save its content in the Chrome Extension store.
+ * @param stateName
+ */
+ public updateState(stateName: string) {
+ const control = this.form.controls.states.controls[stateName];
+ const activeState = this.activeState();
+ if (control.valid && activeState?.name === stateName) {
+ this.saveState(stateName, control.value.name!, control.value.color || 'black');
+ }
+ }
+
+ /**
+ * Create a new state and save its content in the Chrome Extension store.
+ */
+ public saveNewState() {
+ if (this.form.value.newStateName) {
+ this.saveState(this.form.value.newStateName, this.form.value.newStateName, this.form.value.newStateColor || 'black');
+ this.form.controls.states.addControl(
+ this.form.value.newStateName,
+ createStateForm(this.form.value.newStateName, this.form.value.newStateColor)
+ );
+ this.form.controls.newStateColor.reset();
+ this.form.controls.newStateName.reset();
+ }
+ }
+
+ /**
+ * Remove a state from the Chrome Extension application and store.
+ * Note that the active store cannot be deleted.
+ *
+ * @param stateName
+ */
+ public deleteState(stateName: string) {
+ if (this.activeState()?.name !== stateName) {
+ this.stateService.deleteState(stateName);
+ }
+ }
+
+ /**
+ * Download a state as a json file
+ *
+ * @param stateName
+ */
+ public exportState(stateName: string) {
+ const state = this.states()[stateName];
+ if (!state) {
+ return;
+ }
+ const a = this.document.createElement('a');
+ const file = new Blob([JSON.stringify(state)], { type: 'text/plain' });
+ a.href = URL.createObjectURL(file);
+ a.download = `${state.name}.json`;
+ a.click();
+ }
+
+ /**
+ * Download a state file, add it to the state list and share it .
+ *
+ * @param event
+ */
+ public async onFileChange(event: InputEvent) {
+ try {
+ const element = event.target as HTMLInputElement;
+ let fileContent: string | undefined;
+ try {
+ fileContent = element.files && element.files.length > 0 ? await element.files[0].text() : undefined;
+ } catch {
+ throw new Error('Unable to read file');
+ }
+ if (!fileContent) {
+ throw new Error('Empty file');
+ }
+ let state: State;
+ try {
+ state = JSON.parse(fileContent) as State;
+ } catch {
+ throw new Error('Not a JSON file');
+ }
+ if (
+ typeof state.name === 'string'
+ && typeof state.color === 'string'
+ && typeof state.colorContrast === 'string'
+ && (
+ typeof state.configurations === 'undefined'
+ || typeof state.configurations === 'object'
+ )
+ && (
+ typeof state.localizations === 'undefined'
+ || (
+ typeof state.localizations === 'object'
+ && Object.values(state.localizations).every((lang) =>
+ typeof lang === 'object'
+ && Object.values(lang).every((loc) => typeof loc === 'string')
+ )
+ )
+ )
+ && (
+ typeof state.stylingVariables === 'undefined'
+ || (
+ typeof state.stylingVariables === 'object'
+ && Object.values(state.stylingVariables).every((variable) => typeof variable === 'string')
+ )
+ )
+ ) {
+ if (Object.keys(this.states()).some((stateName) => stateName === state.name)) {
+ throw new Error(`${state.name} already exists`);
+ }
+ this.stateService.updateState(
+ state.name,
+ {
+ name: state.name,
+ color: state.color,
+ colorContrast: state.colorContrast,
+ configurations: state.configurations,
+ localizations: state.localizations,
+ stylingVariables: state.stylingVariables
+ }
+ );
+ this.downloadState.set({ text: `${state.name} imported correctly`, success: true });
+ } else {
+ throw new Error('Invalid state');
+ }
+ } catch (e) {
+ this.downloadState.set({ text: (e as Error).message, success: false });
+ } finally {
+ // Clear the input once processed
+ (event.target as HTMLInputElement).value = '';
+ }
+ }
+}
diff --git a/apps/chrome-devtools/src/app-devtools/state-panel/state-panel.template.html b/apps/chrome-devtools/src/app-devtools/state-panel/state-panel.template.html
new file mode 100644
index 0000000000..2d467c952c
--- /dev/null
+++ b/apps/chrome-devtools/src/app-devtools/state-panel/state-panel.template.html
@@ -0,0 +1,59 @@
+
diff --git a/apps/chrome-devtools/src/app-devtools/theming-panel/theming-panel-pres.component.ts b/apps/chrome-devtools/src/app-devtools/theming-panel/theming-panel-pres.component.ts
index 2fbfdb5d48..691193fb6b 100644
--- a/apps/chrome-devtools/src/app-devtools/theming-panel/theming-panel-pres.component.ts
+++ b/apps/chrome-devtools/src/app-devtools/theming-panel/theming-panel-pres.component.ts
@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, computed, effect, type OnDestroy, type Signal, ViewEncapsulation } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, effect, inject, type OnDestroy, type Signal, untracked, ViewEncapsulation } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { DfTooltipModule } from '@design-factory/design-factory';
@@ -6,8 +6,8 @@ import { NgbAccordionModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstr
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 { map, startWith, throttleTime } from 'rxjs/operators';
+import { ChromeExtensionConnectionService, filterAndMapMessage, StateService } from '../../services';
import { DEFAULT_PALETTE_VARIANT, getPaletteColors } from './color.helpers';
import { AccessibilityConstrastScorePipe, ConstrastPipe, HexColorPipe } from './color.pipe';
import { getVariant, resolveVariable, searchFn } from './common';
@@ -63,6 +63,10 @@ export interface VariableGroup {
]
})
export class ThemingPanelPresComponent implements OnDestroy {
+ private readonly stateService = inject(StateService);
+ public readonly activeStateName = computed(() => this.stateService.activeState()?.name);
+ public readonly themingActiveStateOverrides = computed(() => this.stateService.activeState()?.stylingVariables || {});
+ public readonly themingLocalStateOverrides = computed(() => this.stateService.localState()?.stylingVariables || {});
public readonly resolvedVariables: Signal>;
public readonly variablesMap: Signal>;
public readonly numberOfVariables: Signal;
@@ -81,13 +85,14 @@ export class ThemingPanelPresComponent implements OnDestroy {
private readonly runtimeValues = toSignal(this.runtimeValues$, { initialValue: {} });
constructor(
- connectionService: ChromeExtensionConnectionService
+ private readonly connectionService: ChromeExtensionConnectionService
) {
this.variables$ = connectionService.message$.pipe(
- filter((message): message is GetStylingVariableContentMessage => message.dataType === 'getStylingVariable'),
- map((message) => message.variables),
- startWith([]),
- shareReplay({ refCount: true, bufferSize: 1 })
+ filterAndMapMessage(
+ (message): message is GetStylingVariableContentMessage => message.dataType === 'getStylingVariable',
+ (message) => message.variables
+ ),
+ startWith([])
);
this.variables = toSignal(this.variables$, { initialValue: [] });
this.variablesMap = computed(() => this.variables().reduce((acc: Record, curr) => {
@@ -100,6 +105,35 @@ export class ThemingPanelPresComponent implements OnDestroy {
}, {}));
this.numberOfVariables = computed(() => Object.keys(this.resolvedVariables()).length);
+ effect(() => {
+ const variablesControl = this.form.controls.variables;
+ this.variables().forEach((variable) => {
+ const initialValue = variable.runtimeValue ?? variable.defaultValue;
+ const control = variablesControl.controls[variable.name];
+ if (!control) {
+ const newControl = new FormControl(initialValue);
+ variablesControl.addControl(variable.name, newControl);
+ this.subscription.add(
+ newControl.valueChanges.pipe(
+ throttleTime(THROTTLE_TIME, undefined, { trailing: true })
+ ).subscribe((newValue) => {
+ const update = {
+ [variable.name]: (newValue !== variable.defaultValue ? newValue : null) ?? null
+ };
+ this.stateService.updateLocalState({
+ stylingVariables: update
+ });
+ connectionService.sendMessage('updateStylingVariables', {
+ variables: update
+ });
+ })
+ );
+ } else {
+ control.setValue(initialValue, { emitEvent: false });
+ }
+ });
+ });
+
const search = toSignal(this.form.controls.search.valueChanges.pipe(
map((value) => (value || '').toLowerCase()),
throttleTime(THROTTLE_TIME, undefined, { trailing: true })
@@ -161,40 +195,28 @@ export class ThemingPanelPresComponent implements OnDestroy {
});
});
+ const stylingVariables = computed(() => this.stateService.activeState()?.stylingVariables || {});
+
effect(() => {
- const variablesControl = this.form.controls.variables;
- this.variables().forEach((variable) => {
- const value = variable.runtimeValue ?? variable.defaultValue;
- const control = variablesControl.controls[variable.name];
- if (!control) {
- const newControl = new FormControl(value);
- variablesControl.addControl(variable.name, newControl);
- this.subscription.add(
- newControl.valueChanges.pipe(
- throttleTime(THROTTLE_TIME, undefined, { trailing: true })
- ).subscribe((newValue) => {
- connectionService.sendMessage('updateStylingVariables', {
- variables: {
- [variable.name]: newValue
- }
- });
- })
- );
- } else {
- control.setValue(value, { emitEvent: false });
- }
+ Object.entries(stylingVariables()).forEach(([variableName, value]) => {
+ this.changeColor(variableName, value);
});
});
+ effect(() => {
+ if (!this.activeStateName()) {
+ Object.keys(this.form.controls.variables.controls)
+ .forEach((variableName) => this.onColorReset(untracked(this.variablesMap)[variableName]));
+ }
+ });
+
connectionService.sendMessage(
'requestMessages',
- {
- only: 'getStylingVariable'
- }
+ { only: ['getStylingVariable'] }
);
}
- private changeColor(variableName: string, value: string) {
+ private changeColor(variableName: string, value: string | null) {
this.form.controls.variables.controls[variableName].setValue(value);
}
@@ -235,7 +257,18 @@ export class ThemingPanelPresComponent implements OnDestroy {
* @param variable
*/
public onColorReset(variable: StylingVariable) {
- this.changeColor(variable.name, variable.defaultValue);
+ const stateValue = this.themingActiveStateOverrides()[variable.name];
+ const localValue = this.themingLocalStateOverrides()[variable.name];
+ if (stateValue && stateValue !== localValue) {
+ this.changeColor(variable.name, stateValue);
+ } else {
+ this.changeColor(variable.name, variable.defaultValue);
+ this.connectionService.sendMessage('updateStylingVariables', {
+ variables: {
+ [variable.name]: null
+ }
+ });
+ }
}
/**
@@ -258,12 +291,13 @@ export class ThemingPanelPresComponent implements OnDestroy {
/**
* Handler for palette color reset
* @param palette
+ * @param resetValue
* @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));
+ palette.variables.forEach((variable) => this.onColorReset(variable));
}
}
diff --git a/apps/chrome-devtools/src/app-devtools/theming-panel/theming-panel-pres.template.html b/apps/chrome-devtools/src/app-devtools/theming-panel/theming-panel-pres.template.html
index 404d0fdb6a..4049afe223 100644
--- a/apps/chrome-devtools/src/app-devtools/theming-panel/theming-panel-pres.template.html
+++ b/apps/chrome-devtools/src/app-devtools/theming-panel/theming-panel-pres.template.html
@@ -32,8 +32,15 @@
- @if (form.value.variables?.[group.defaultVariable.name] !== group.defaultVariable.defaultValue) {
-
+ @if (themingActiveStateOverrides()[group.defaultVariable.name] || themingLocalStateOverrides()[group.defaultVariable.name]) {
+