Files
Toju/toju-app/src/app/domains/theme/application/services/theme.service.ts
2026-05-25 16:51:44 +02:00

687 lines
22 KiB
TypeScript

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 {
BUILT_IN_THEME_PRESETS,
DEFAULT_THEME_JSON,
createDefaultThemeDocument,
createDefaultThemeLayout,
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 isEmptyRecord(value: Record<string, unknown>): boolean {
return Object.keys(value).length === 0;
}
function hasTokenOverrides(document: ThemeDocument): boolean {
return (
!isEmptyRecord(document.tokens.colors) ||
!isEmptyRecord(document.tokens.spacing) ||
!isEmptyRecord(document.tokens.radii) ||
!isEmptyRecord(document.tokens.effects)
);
}
function compactThemeElements(elements: ThemeDocument['elements']): ThemeDocument['elements'] {
return Object.fromEntries(Object.entries(elements).filter(([_key, styles]) => !isEmptyRecord(styles as Record<string, unknown>)));
}
function stringifyTheme(document: ThemeDocument): string {
const jsonDocument: Partial<ThemeDocument> = {
meta: document.meta
};
const compactElements = compactThemeElements(document.elements);
if (document.css.trim().length > 0) {
jsonDocument.css = document.css;
}
if (hasTokenOverrides(document)) {
jsonDocument.tokens = document.tokens;
}
if (!isEmptyRecord(document.layout)) {
jsonDocument.layout = document.layout;
}
if (!isEmptyRecord(compactElements)) {
jsonDocument.elements = compactElements;
}
if (!isEmptyRecord(document.animations)) {
jsonDocument.animations = document.animations;
}
return JSON.stringify(jsonDocument, null, 2);
}
function looksLikeImageReference(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../');
}
function normalizeBackgroundImageLayer(value: string): string {
const trimmedValue = value.trim();
if (!looksLikeImageReference(trimmedValue)) {
return trimmedValue;
}
return `url("${trimmedValue.replace(/"/g, '\\"')}")`;
}
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>;
readonly builtInPresets = BUILT_IN_THEME_PRESETS;
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;
private cssStyleElement: 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();
this.syncCssStylesheet();
}
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;
}
buildDraftTextWithCss(css: string): string | null {
const theme = this.composeDraftThemeWithCss(css);
return theme ? stringifyTheme(theme) : null;
}
applyCssOnlyTheme(css: string): boolean {
const theme = this.composeDraftThemeWithCss(css);
if (!theme) {
return false;
}
const formatted = stringifyTheme(theme);
this.commitTheme(theme, formatted, 'CSS applied over the JSON theme.');
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.syncCssStylesheet();
this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
}
applyBuiltInPreset(name: string): boolean {
const preset = this.builtInPresets.find((builtInPreset) => builtInPreset.theme.meta.name === name || builtInPreset.key === name);
if (!preset) {
this.setStatusMessage('Built-in theme preset not found.');
return false;
}
const theme = structuredClone(preset.theme);
const result = validateThemeDocument(theme);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? 'Built-in theme preset could not be applied.');
return false;
}
this.commitTheme(result.value, stringifyTheme(result.value), `${result.value.meta.name} preset applied.`);
return true;
}
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 {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
return;
}
const draftJson = JSON.parse(this.draftTextInternal()) as Record<string, unknown>;
const existingElements = draftJson['elements'];
const elements =
existingElements && typeof existingElements === 'object' && !Array.isArray(existingElements)
? { ...(existingElements as Record<string, unknown>) }
: {};
elements[key] = elements[key] ?? {};
draftJson['elements'] = elements;
const result = validateThemeDocument(draftJson);
if (!result.valid || !result.value) {
this.setStatusMessage('The structured change could not be validated.');
return;
}
const formatted = JSON.stringify(draftJson, null, 2);
this.draftThemeInternal.set(result.value);
this.draftTextInternal.set(formatted);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveDraftThemeText(formatted);
this.setStatusMessage(`Prepared ${key} in the theme draft.`);
}
ensureLayoutEntry(key: string): void {
this.updateStructuredDraft(
(draft) => {
const defaults = createDefaultThemeLayout();
draft.layout[key] = draft.layout[key] ?? defaults[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)
.map((layer) => normalizeBackgroundImageLayer(layer));
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 = createDefaultThemeLayout();
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults[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.syncCssStylesheet();
this.setStatusMessage(successMessage);
}
private composeDraftThemeWithCss(css: string): ThemeDocument | null {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before applying CSS over the theme draft.');
return null;
}
const theme = {
...structuredClone(this.draftThemeInternal()),
css
};
const result = validateThemeDocument(theme);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? 'The CSS-only theme could not be applied.');
return null;
}
return result.value;
}
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 syncCssStylesheet(): void {
const css = this.activeThemeInternal().css.trim();
if (!this.cssStyleElement) {
this.cssStyleElement = this.documentRef.createElement('style');
this.cssStyleElement.setAttribute('data-toju-theme-css', 'true');
this.documentRef.head.appendChild(this.cssStyleElement);
}
this.cssStyleElement.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);
}
}