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): 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))); } function stringifyTheme(document: ThemeDocument): string { const jsonDocument: Partial = { 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; readonly activeThemeText: Signal; readonly draftTheme: Signal; readonly draftText: Signal; readonly draftIsValid: Signal; readonly draftErrors: Signal; readonly statusMessage: Signal; readonly activeThemeName: Signal; readonly knownAnimationClasses: Signal; readonly isDraftDirty: Signal; readonly builtInPresets = BUILT_IN_THEME_PRESETS; private readonly documentRef = inject(DOCUMENT); private readonly activeThemeInternal = signal(createDefaultThemeDocument()); private readonly activeThemeTextInternal = signal(DEFAULT_THEME_JSON); private readonly draftThemeInternal = signal(createDefaultThemeDocument()); private readonly draftTextInternal = signal(DEFAULT_THEME_JSON); private readonly draftIsValidInternal = signal(true); private readonly draftErrorsInternal = signal([]); private readonly statusMessageInternal = signal(null); private initialized = false; private statusTimeoutId: ReturnType | 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; const existingElements = draftJson['elements']; const elements = existingElements && typeof existingElements === 'object' && !Array.isArray(existingElements) ? { ...(existingElements as Record) } : {}; 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 { const elementTheme = this.activeThemeInternal().elements[key] ?? {}; const styles: Record = {}; 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 { 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 { 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 { const styles: Record = {}; 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); } }