feat: Theme studio v2
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
DEFAULT_THEME_JSON,
|
||||
createDefaultThemeDocument,
|
||||
createDefaultThemeLayout,
|
||||
isLegacyDefaultThemeDocument
|
||||
} from '../../domain/logic/theme-defaults.logic';
|
||||
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
|
||||
@@ -35,14 +36,68 @@ function toKebabCase(value: string): string {
|
||||
.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 {
|
||||
return JSON.stringify(document, null, 2);
|
||||
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;
|
||||
return isLegacyDefaultThemeDocument(document) ? createDefaultThemeDocument() : document;
|
||||
}
|
||||
|
||||
const hostStylePropertyKeys = [
|
||||
@@ -96,6 +151,7 @@ export class ThemeService {
|
||||
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();
|
||||
@@ -159,6 +215,7 @@ export class ThemeService {
|
||||
}
|
||||
|
||||
this.syncAnimationStylesheet();
|
||||
this.syncCssStylesheet();
|
||||
}
|
||||
|
||||
updateDraftText(text: string): void {
|
||||
@@ -203,12 +260,26 @@ export class ThemeService {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadThemeText(
|
||||
text: string,
|
||||
mode: 'draft' | 'apply',
|
||||
successMessage: string,
|
||||
sourceLabel = 'theme'
|
||||
): boolean {
|
||||
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) {
|
||||
@@ -250,9 +321,8 @@ export class ThemeService {
|
||||
saveActiveThemeText(defaultText);
|
||||
saveDraftThemeText(defaultText);
|
||||
this.syncAnimationStylesheet();
|
||||
this.setStatusMessage(reason === 'shortcut'
|
||||
? 'Theme reset to the default preset by shortcut.'
|
||||
: 'Theme reset to the default preset.');
|
||||
this.syncCssStylesheet();
|
||||
this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
|
||||
}
|
||||
|
||||
handleGlobalShortcut(event: KeyboardEvent): boolean {
|
||||
@@ -268,41 +338,71 @@ export class ThemeService {
|
||||
}
|
||||
|
||||
ensureElementEntry(key: string): void {
|
||||
this.updateStructuredDraft((draft) => {
|
||||
draft.elements[key] = draft.elements[key] ?? {};
|
||||
}, false, `Prepared ${key} in the theme draft.`);
|
||||
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 = createDefaultThemeDocument();
|
||||
this.updateStructuredDraft(
|
||||
(draft) => {
|
||||
const defaults = createDefaultThemeLayout();
|
||||
|
||||
draft.layout[key] = draft.layout[key] ?? defaults.layout[key];
|
||||
}, false, `Prepared ${key} layout in the theme draft.`);
|
||||
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.`);
|
||||
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.`);
|
||||
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> {
|
||||
@@ -314,7 +414,8 @@ export class ThemeService {
|
||||
}
|
||||
|
||||
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
|
||||
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0);
|
||||
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0)
|
||||
.map((layer) => normalizeBackgroundImageLayer(layer));
|
||||
|
||||
if (backgroundLayers.length > 0) {
|
||||
styles['backgroundImage'] = backgroundLayers.join(', ');
|
||||
@@ -338,9 +439,7 @@ export class ThemeService {
|
||||
getAnimationClass(key: string): string | null {
|
||||
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
|
||||
|
||||
return animationClass && animationClass.length > 0
|
||||
? animationClass
|
||||
: null;
|
||||
return animationClass && animationClass.length > 0 ? animationClass : null;
|
||||
}
|
||||
|
||||
getLink(key: string): string | null {
|
||||
@@ -367,17 +466,15 @@ export class ThemeService {
|
||||
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))`),
|
||||
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];
|
||||
const defaults = createDefaultThemeLayout();
|
||||
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults[key];
|
||||
|
||||
if (!layoutEntry) {
|
||||
return {};
|
||||
@@ -391,11 +488,7 @@ export class ThemeService {
|
||||
};
|
||||
}
|
||||
|
||||
updateStructuredDraft(
|
||||
mutator: (draft: ThemeDocument) => void,
|
||||
applyImmediately: boolean,
|
||||
successMessage: string
|
||||
): void {
|
||||
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;
|
||||
@@ -438,9 +531,30 @@ export class ThemeService {
|
||||
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);
|
||||
@@ -465,9 +579,7 @@ export class ThemeService {
|
||||
}
|
||||
|
||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
|
||||
const cssVariableName = tokenName === 'radius'
|
||||
? '--radius'
|
||||
: `--theme-radius-${toKebabCase(tokenName)}`;
|
||||
const cssVariableName = tokenName === 'radius' ? '--radius' : `--theme-radius-${toKebabCase(tokenName)}`;
|
||||
|
||||
styles[cssVariableName] = tokenValue;
|
||||
}
|
||||
@@ -495,6 +607,18 @@ export class ThemeService {
|
||||
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 = [
|
||||
|
||||
Reference in New Issue
Block a user