687 lines
22 KiB
TypeScript
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);
|
|
}
|
|
}
|