refactor: stricter domain: theme
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
Injectable,
|
||||
Signal,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeAnimationDefinition,
|
||||
ThemeContainerKey,
|
||||
ThemeDocument,
|
||||
ThemeElementStyleProperty,
|
||||
ThemeElementStyles
|
||||
} from '../../domain/models/theme.model';
|
||||
import {
|
||||
DEFAULT_THEME_JSON,
|
||||
createDefaultThemeDocument,
|
||||
isLegacyDefaultThemeDocument
|
||||
} from '../../domain/logic/theme-defaults.logic';
|
||||
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
|
||||
import { findThemeLayoutContainer } from '../../domain/logic/theme-registry.logic';
|
||||
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
|
||||
import {
|
||||
loadThemeStorageSnapshot,
|
||||
saveActiveThemeText,
|
||||
saveDraftThemeText
|
||||
} from '../../infrastructure/util/theme-storage.util';
|
||||
|
||||
function toKebabCase(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.replace(/[_\s]+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function stringifyTheme(document: ThemeDocument): string {
|
||||
return JSON.stringify(document, null, 2);
|
||||
}
|
||||
|
||||
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
|
||||
return isLegacyDefaultThemeDocument(document)
|
||||
? createDefaultThemeDocument()
|
||||
: document;
|
||||
}
|
||||
|
||||
const hostStylePropertyKeys = [
|
||||
'width',
|
||||
'height',
|
||||
'minWidth',
|
||||
'minHeight',
|
||||
'maxWidth',
|
||||
'maxHeight',
|
||||
'position',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'padding',
|
||||
'margin',
|
||||
'border',
|
||||
'borderRadius',
|
||||
'backgroundColor',
|
||||
'color',
|
||||
'backgroundSize',
|
||||
'backgroundPosition',
|
||||
'backgroundRepeat',
|
||||
'boxShadow',
|
||||
'backdropFilter'
|
||||
] as const satisfies readonly (keyof ThemeElementStyles)[];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeService {
|
||||
readonly activeTheme: Signal<ThemeDocument>;
|
||||
readonly activeThemeText: Signal<string>;
|
||||
readonly draftTheme: Signal<ThemeDocument>;
|
||||
readonly draftText: Signal<string>;
|
||||
readonly draftIsValid: Signal<boolean>;
|
||||
readonly draftErrors: Signal<string[]>;
|
||||
readonly statusMessage: Signal<string | null>;
|
||||
readonly activeThemeName: Signal<string>;
|
||||
readonly knownAnimationClasses: Signal<string[]>;
|
||||
readonly isDraftDirty: Signal<boolean>;
|
||||
|
||||
private readonly documentRef = inject(DOCUMENT);
|
||||
|
||||
private readonly activeThemeInternal = signal<ThemeDocument>(createDefaultThemeDocument());
|
||||
private readonly activeThemeTextInternal = signal(DEFAULT_THEME_JSON);
|
||||
private readonly draftThemeInternal = signal<ThemeDocument>(createDefaultThemeDocument());
|
||||
private readonly draftTextInternal = signal(DEFAULT_THEME_JSON);
|
||||
private readonly draftIsValidInternal = signal(true);
|
||||
private readonly draftErrorsInternal = signal<string[]>([]);
|
||||
private readonly statusMessageInternal = signal<string | null>(null);
|
||||
|
||||
private initialized = false;
|
||||
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private animationStyleElement: HTMLStyleElement | null = null;
|
||||
|
||||
constructor() {
|
||||
this.activeTheme = this.activeThemeInternal.asReadonly();
|
||||
this.activeThemeText = this.activeThemeTextInternal.asReadonly();
|
||||
this.draftTheme = this.draftThemeInternal.asReadonly();
|
||||
this.draftText = this.draftTextInternal.asReadonly();
|
||||
this.draftIsValid = this.draftIsValidInternal.asReadonly();
|
||||
this.draftErrors = this.draftErrorsInternal.asReadonly();
|
||||
this.statusMessage = this.statusMessageInternal.asReadonly();
|
||||
this.activeThemeName = computed(() => this.activeThemeInternal().meta.name);
|
||||
this.knownAnimationClasses = computed(() => Object.keys(this.draftThemeInternal().animations));
|
||||
this.isDraftDirty = computed(() => {
|
||||
return this.draftTextInternal().trim() !== this.activeThemeTextInternal().trim();
|
||||
});
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
const storageSnapshot = loadThemeStorageSnapshot();
|
||||
const activeText = storageSnapshot.activeText ?? DEFAULT_THEME_JSON;
|
||||
const activeResult = this.parseAndValidateTheme(activeText, 'saved active theme');
|
||||
|
||||
if (activeResult.valid && activeResult.value) {
|
||||
const resolvedTheme = resolveBuiltInDefaultMigration(activeResult.value);
|
||||
const formatted = stringifyTheme(resolvedTheme);
|
||||
|
||||
this.activeThemeInternal.set(resolvedTheme);
|
||||
this.activeThemeTextInternal.set(formatted);
|
||||
saveActiveThemeText(formatted);
|
||||
} else {
|
||||
const defaultTheme = createDefaultThemeDocument();
|
||||
const defaultText = stringifyTheme(defaultTheme);
|
||||
|
||||
this.activeThemeInternal.set(defaultTheme);
|
||||
this.activeThemeTextInternal.set(defaultText);
|
||||
saveActiveThemeText(defaultText);
|
||||
}
|
||||
|
||||
const draftText = storageSnapshot.draftText ?? this.activeThemeTextInternal();
|
||||
const draftResult = this.parseAndValidateTheme(draftText, 'saved draft theme');
|
||||
|
||||
if (draftResult.valid && draftResult.value) {
|
||||
const resolvedDraftTheme = resolveBuiltInDefaultMigration(draftResult.value);
|
||||
const formattedDraft = stringifyTheme(resolvedDraftTheme);
|
||||
|
||||
this.draftThemeInternal.set(resolvedDraftTheme);
|
||||
this.draftTextInternal.set(formattedDraft);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveDraftThemeText(formattedDraft);
|
||||
} else {
|
||||
this.draftThemeInternal.set(this.activeThemeInternal());
|
||||
this.draftTextInternal.set(this.activeThemeTextInternal());
|
||||
this.draftIsValidInternal.set(false);
|
||||
this.draftErrorsInternal.set(draftResult.errors);
|
||||
}
|
||||
|
||||
this.syncAnimationStylesheet();
|
||||
}
|
||||
|
||||
updateDraftText(text: string): void {
|
||||
this.draftTextInternal.set(text);
|
||||
saveDraftThemeText(text);
|
||||
|
||||
const result = this.parseAndValidateTheme(text, 'theme draft');
|
||||
|
||||
if (result.valid && result.value) {
|
||||
this.draftThemeInternal.set(result.value);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.draftIsValidInternal.set(false);
|
||||
this.draftErrorsInternal.set(result.errors);
|
||||
}
|
||||
|
||||
formatDraft(): void {
|
||||
if (!this.draftIsValidInternal()) {
|
||||
this.setStatusMessage('Fix JSON errors before formatting the theme draft.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = stringifyTheme(this.draftThemeInternal());
|
||||
|
||||
this.draftTextInternal.set(formatted);
|
||||
saveDraftThemeText(formatted);
|
||||
this.setStatusMessage('Theme draft formatted.');
|
||||
}
|
||||
|
||||
applyDraft(): boolean {
|
||||
if (!this.draftIsValidInternal()) {
|
||||
this.setStatusMessage('The current draft has validation errors. The previous working theme is still active.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const formatted = stringifyTheme(this.draftThemeInternal());
|
||||
|
||||
this.commitTheme(this.draftThemeInternal(), formatted, 'Theme applied.');
|
||||
return true;
|
||||
}
|
||||
|
||||
loadThemeText(
|
||||
text: string,
|
||||
mode: 'draft' | 'apply',
|
||||
successMessage: string,
|
||||
sourceLabel = 'theme'
|
||||
): boolean {
|
||||
const result = this.parseAndValidateTheme(text, sourceLabel);
|
||||
|
||||
if (!result.valid || !result.value) {
|
||||
this.setStatusMessage(result.errors[0] ?? `The ${sourceLabel} could not be loaded.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedTheme = resolveBuiltInDefaultMigration(result.value);
|
||||
const formatted = stringifyTheme(resolvedTheme);
|
||||
|
||||
if (mode === 'apply') {
|
||||
this.commitTheme(resolvedTheme, formatted, successMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.draftThemeInternal.set(resolvedTheme);
|
||||
this.draftTextInternal.set(formatted);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveDraftThemeText(formatted);
|
||||
this.setStatusMessage(successMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
announceStatus(message: string): void {
|
||||
this.setStatusMessage(message);
|
||||
}
|
||||
|
||||
resetToDefault(reason: 'button' | 'shortcut' = 'button'): void {
|
||||
const defaultTheme = createDefaultThemeDocument();
|
||||
const defaultText = stringifyTheme(defaultTheme);
|
||||
|
||||
this.activeThemeInternal.set(defaultTheme);
|
||||
this.activeThemeTextInternal.set(defaultText);
|
||||
this.draftThemeInternal.set(defaultTheme);
|
||||
this.draftTextInternal.set(defaultText);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveActiveThemeText(defaultText);
|
||||
saveDraftThemeText(defaultText);
|
||||
this.syncAnimationStylesheet();
|
||||
this.setStatusMessage(reason === 'shortcut'
|
||||
? 'Theme reset to the default preset by shortcut.'
|
||||
: 'Theme reset to the default preset.');
|
||||
}
|
||||
|
||||
handleGlobalShortcut(event: KeyboardEvent): boolean {
|
||||
const usesModifier = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (!usesModifier || !event.shiftKey || event.code !== 'Digit0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.resetToDefault('shortcut');
|
||||
return true;
|
||||
}
|
||||
|
||||
ensureElementEntry(key: string): void {
|
||||
this.updateStructuredDraft((draft) => {
|
||||
draft.elements[key] = draft.elements[key] ?? {};
|
||||
}, false, `Prepared ${key} in the theme draft.`);
|
||||
}
|
||||
|
||||
ensureLayoutEntry(key: string): void {
|
||||
this.updateStructuredDraft((draft) => {
|
||||
const defaults = createDefaultThemeDocument();
|
||||
|
||||
draft.layout[key] = draft.layout[key] ?? defaults.layout[key];
|
||||
}, false, `Prepared ${key} layout in the theme draft.`);
|
||||
}
|
||||
|
||||
setElementStyle(
|
||||
key: string,
|
||||
property: ThemeElementStyleProperty,
|
||||
value: string | number,
|
||||
applyImmediately = true
|
||||
): void {
|
||||
this.updateStructuredDraft((draft) => {
|
||||
draft.elements[key] = {
|
||||
...draft.elements[key],
|
||||
[property]: value
|
||||
};
|
||||
}, applyImmediately, `${key} updated.`);
|
||||
}
|
||||
|
||||
setAnimation(
|
||||
key: string,
|
||||
definition: ThemeAnimationDefinition = createAnimationStarterDefinition(),
|
||||
applyImmediately = true
|
||||
): void {
|
||||
this.updateStructuredDraft((draft) => {
|
||||
draft.animations[key] = definition;
|
||||
}, applyImmediately, `Animation ${key} updated.`);
|
||||
}
|
||||
|
||||
getHostStyles(key: string): Record<string, string> {
|
||||
const elementTheme = this.activeThemeInternal().elements[key] ?? {};
|
||||
const styles: Record<string, string> = {};
|
||||
|
||||
if (key === 'appRoot') {
|
||||
Object.assign(styles, this.buildTokenStyles(this.activeThemeInternal()));
|
||||
}
|
||||
|
||||
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
|
||||
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0);
|
||||
|
||||
if (backgroundLayers.length > 0) {
|
||||
styles['backgroundImage'] = backgroundLayers.join(', ');
|
||||
}
|
||||
|
||||
for (const property of hostStylePropertyKeys) {
|
||||
const value = elementTheme[property];
|
||||
|
||||
if (value) {
|
||||
styles[property] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof elementTheme.opacity === 'number') {
|
||||
styles['opacity'] = `${elementTheme.opacity}`;
|
||||
}
|
||||
|
||||
return styles;
|
||||
}
|
||||
|
||||
getAnimationClass(key: string): string | null {
|
||||
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
|
||||
|
||||
return animationClass && animationClass.length > 0
|
||||
? animationClass
|
||||
: null;
|
||||
}
|
||||
|
||||
getLink(key: string): string | null {
|
||||
return this.activeThemeInternal().elements[key]?.link ?? null;
|
||||
}
|
||||
|
||||
getTextOverride(key: string): string | null {
|
||||
return this.activeThemeInternal().elements[key]?.textOverride ?? null;
|
||||
}
|
||||
|
||||
getIcon(key: string): string | null {
|
||||
return this.activeThemeInternal().elements[key]?.icon ?? null;
|
||||
}
|
||||
|
||||
getLayoutContainerStyles(containerKey: ThemeContainerKey): Record<string, string> {
|
||||
const container = findThemeLayoutContainer(containerKey);
|
||||
|
||||
if (!container) {
|
||||
return {
|
||||
display: 'grid'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: container.templateRows ?? (container.rows === 1
|
||||
? 'minmax(0, 1fr)'
|
||||
: `repeat(${container.rows}, minmax(0, 1fr))`),
|
||||
minHeight: '0',
|
||||
minWidth: '0'
|
||||
};
|
||||
}
|
||||
|
||||
getLayoutItemStyles(key: string): Record<string, string> {
|
||||
const defaults = createDefaultThemeDocument();
|
||||
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults.layout[key];
|
||||
|
||||
if (!layoutEntry) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
gridColumn: `${layoutEntry.grid.x + 1} / span ${layoutEntry.grid.w}`,
|
||||
gridRow: `${layoutEntry.grid.y + 1} / span ${layoutEntry.grid.h}`,
|
||||
minWidth: '0',
|
||||
minHeight: '0'
|
||||
};
|
||||
}
|
||||
|
||||
updateStructuredDraft(
|
||||
mutator: (draft: ThemeDocument) => void,
|
||||
applyImmediately: boolean,
|
||||
successMessage: string
|
||||
): void {
|
||||
if (!this.draftIsValidInternal()) {
|
||||
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDraft = structuredClone(this.draftThemeInternal());
|
||||
|
||||
mutator(nextDraft);
|
||||
|
||||
const result = validateThemeDocument(nextDraft);
|
||||
|
||||
if (!result.valid || !result.value) {
|
||||
this.setStatusMessage('The structured change could not be validated.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = stringifyTheme(result.value);
|
||||
|
||||
this.draftThemeInternal.set(result.value);
|
||||
this.draftTextInternal.set(formatted);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveDraftThemeText(formatted);
|
||||
|
||||
if (applyImmediately) {
|
||||
this.commitTheme(result.value, formatted, successMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStatusMessage(successMessage);
|
||||
}
|
||||
|
||||
private commitTheme(theme: ThemeDocument, text: string, successMessage: string): void {
|
||||
this.activeThemeInternal.set(theme);
|
||||
this.activeThemeTextInternal.set(text);
|
||||
this.draftThemeInternal.set(theme);
|
||||
this.draftTextInternal.set(text);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveActiveThemeText(text);
|
||||
saveDraftThemeText(text);
|
||||
this.syncAnimationStylesheet();
|
||||
this.setStatusMessage(successMessage);
|
||||
}
|
||||
|
||||
private parseAndValidateTheme(text: string, label: string) {
|
||||
try {
|
||||
return validateThemeDocument(JSON.parse(text) as unknown);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`${label} could not be parsed: ${error instanceof Error ? error.message : 'unknown JSON error'}`],
|
||||
value: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildTokenStyles(theme: ThemeDocument): Record<string, string> {
|
||||
const styles: Record<string, string> = {};
|
||||
|
||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.colors)) {
|
||||
styles[`--${toKebabCase(tokenName)}`] = tokenValue;
|
||||
}
|
||||
|
||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.spacing)) {
|
||||
styles[`--theme-spacing-${toKebabCase(tokenName)}`] = tokenValue;
|
||||
}
|
||||
|
||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
|
||||
const cssVariableName = tokenName === 'radius'
|
||||
? '--radius'
|
||||
: `--theme-radius-${toKebabCase(tokenName)}`;
|
||||
|
||||
styles[cssVariableName] = tokenValue;
|
||||
}
|
||||
|
||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.effects)) {
|
||||
styles[`--theme-effect-${toKebabCase(tokenName)}`] = tokenValue;
|
||||
}
|
||||
|
||||
return styles;
|
||||
}
|
||||
|
||||
private syncAnimationStylesheet(): void {
|
||||
const theme = this.activeThemeInternal();
|
||||
const css = Object.entries(theme.animations)
|
||||
.map(([className, definition]) => this.buildAnimationRule(className, definition))
|
||||
.filter((rule) => rule.length > 0)
|
||||
.join('\n\n');
|
||||
|
||||
if (!this.animationStyleElement) {
|
||||
this.animationStyleElement = this.documentRef.createElement('style');
|
||||
this.animationStyleElement.setAttribute('data-toju-theme-animations', 'true');
|
||||
this.documentRef.head.appendChild(this.animationStyleElement);
|
||||
}
|
||||
|
||||
this.animationStyleElement.textContent = css;
|
||||
}
|
||||
|
||||
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
|
||||
const animationClass = `.${className}`;
|
||||
const declarationLines = [
|
||||
`animation-name: ${className};`,
|
||||
`animation-duration: ${definition.duration ?? '240ms'};`,
|
||||
`animation-timing-function: ${definition.easing ?? 'ease'};`,
|
||||
`animation-delay: ${definition.delay ?? '0ms'};`,
|
||||
`animation-iteration-count: ${definition.iterationCount ?? '1'};`,
|
||||
`animation-fill-mode: ${definition.fillMode ?? 'both'};`,
|
||||
`animation-direction: ${definition.direction ?? 'normal'};`
|
||||
];
|
||||
const classRule = `${animationClass} {\n ${declarationLines.join('\n ')}\n}`;
|
||||
|
||||
if (!definition.keyframes || Object.keys(definition.keyframes).length === 0) {
|
||||
return classRule;
|
||||
}
|
||||
|
||||
const keyframeRule = `@keyframes ${className} {\n${Object.entries(definition.keyframes)
|
||||
.map(([step, declarations]) => {
|
||||
const lines = Object.entries(declarations)
|
||||
.map(([property, value]) => ` ${toKebabCase(property)}: ${value};`)
|
||||
.join('\n');
|
||||
|
||||
return ` ${step} {\n${lines}\n }`;
|
||||
})
|
||||
.join('\n')}\n}`;
|
||||
|
||||
return `${keyframeRule}\n\n${classRule}`;
|
||||
}
|
||||
|
||||
private setStatusMessage(message: string): void {
|
||||
this.statusMessageInternal.set(message);
|
||||
|
||||
if (this.statusTimeoutId) {
|
||||
clearTimeout(this.statusTimeoutId);
|
||||
}
|
||||
|
||||
this.statusTimeoutId = setTimeout(() => {
|
||||
this.statusMessageInternal.set(null);
|
||||
this.statusTimeoutId = null;
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user