feat: Theme studio v2
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
||||
import { getElectronApi } from '../../../../../core/platform/electron/get-electron-api';
|
||||
import {
|
||||
ThemeContainerKey,
|
||||
ThemeElementStyleProperty,
|
||||
@@ -29,7 +30,8 @@ import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.const
|
||||
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
|
||||
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
|
||||
|
||||
type JumpSection = 'elements' | 'layout' | 'animations';
|
||||
type JumpSection = 'elements' | 'layout' | 'animations' | 'css';
|
||||
type ThemeEditorTab = 'json' | 'cssOnly';
|
||||
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||
|
||||
@Component({
|
||||
@@ -59,6 +61,7 @@ export class ThemeSettingsComponent {
|
||||
readonly statusMessage = this.theme.statusMessage;
|
||||
readonly isDraftDirty = this.theme.isDraftDirty;
|
||||
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
||||
readonly activeTheme = this.theme.activeTheme;
|
||||
readonly draftTheme = this.theme.draftTheme;
|
||||
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
||||
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||
@@ -83,6 +86,8 @@ export class ThemeSettingsComponent {
|
||||
];
|
||||
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
||||
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
||||
readonly activeEditorTab = signal<ThemeEditorTab>('json');
|
||||
readonly cssOnlyText = signal('');
|
||||
readonly explorerQuery = signal('');
|
||||
|
||||
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
||||
@@ -103,9 +108,15 @@ export class ThemeSettingsComponent {
|
||||
].filter((value): value is string => value !== null);
|
||||
});
|
||||
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
||||
readonly selectedLayoutContainer = computed(() => {
|
||||
return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0];
|
||||
});
|
||||
readonly selectedElementGrid = computed(() => {
|
||||
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
|
||||
});
|
||||
readonly activeWorkspaceInfo = computed(() => {
|
||||
return this.workspaceTabs.find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs[0];
|
||||
});
|
||||
readonly visiblePropertyHints = computed(() => {
|
||||
const selected = this.selectedElement();
|
||||
|
||||
@@ -156,6 +167,8 @@ export class ThemeSettingsComponent {
|
||||
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
|
||||
if (this.savedThemesAvailable()) {
|
||||
void this.themeLibrary.refresh();
|
||||
}
|
||||
@@ -167,8 +180,16 @@ export class ThemeSettingsComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('inspector');
|
||||
this.selectThemeEntry(pickedKey, 'elements');
|
||||
this.openPickedElementInJson(pickedKey);
|
||||
this.picker.clearSelection();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.activeEditorTab() !== 'json') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cssOnlyText.set(this.draftTheme().css);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -186,10 +207,35 @@ export class ThemeSettingsComponent {
|
||||
this.theme.updateDraftText(value);
|
||||
}
|
||||
|
||||
onCssOnlyEditorValueChange(value: string): void {
|
||||
this.cssOnlyText.set(value);
|
||||
}
|
||||
|
||||
applyDraft(): void {
|
||||
if (this.activeEditorTab() === 'cssOnly') {
|
||||
this.applyCssOnlyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
this.theme.applyDraft();
|
||||
}
|
||||
|
||||
applyCssOnlyTheme(): void {
|
||||
this.theme.applyCssOnlyTheme(this.cssOnlyText());
|
||||
}
|
||||
|
||||
setEditorTab(tab: ThemeEditorTab): void {
|
||||
this.activeEditorTab.set(tab);
|
||||
|
||||
if (tab === 'json') {
|
||||
this.focusEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
setWorkspace(workspace: ThemeStudioWorkspace): void {
|
||||
this.activeWorkspace.set(workspace);
|
||||
|
||||
@@ -202,6 +248,7 @@ export class ThemeSettingsComponent {
|
||||
}
|
||||
|
||||
if (workspace === 'editor') {
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusEditor();
|
||||
}
|
||||
}
|
||||
@@ -223,12 +270,36 @@ export class ThemeSettingsComponent {
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
async exportThemeFile(): Promise<void> {
|
||||
const exportText = this.getExportThemeText();
|
||||
|
||||
if (!exportText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = `${this.sanitizeThemeFileName(this.draftTheme().meta.name)}.json`;
|
||||
const saved = await this.saveTextAsFile(fileName, exportText);
|
||||
|
||||
this.theme.announceStatus(saved ? `${fileName} exported.` : 'Theme export cancelled.');
|
||||
}
|
||||
|
||||
importThemeFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
|
||||
input.value = '';
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.loadThemeFile(file);
|
||||
}
|
||||
|
||||
async copyLlmThemeGuide(): Promise<void> {
|
||||
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
|
||||
|
||||
this.setLlmGuideCopyMessage(copied
|
||||
? 'LLM guide copied.'
|
||||
: 'Manual copy opened.');
|
||||
this.setLlmGuideCopyMessage(copied ? 'LLM guide copied.' : 'Manual copy opened.');
|
||||
}
|
||||
|
||||
startPicker(): void {
|
||||
@@ -285,9 +356,10 @@ export class ThemeSettingsComponent {
|
||||
restoreDefaultTheme(): void {
|
||||
this.theme.resetToDefault('button');
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.selectedContainer.set('roomLayout');
|
||||
this.selectedElementKey.set('chatRoomMainPanel');
|
||||
this.focusJsonAnchor('elements', 'chatRoomMainPanel');
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
selectThemeEntry(key: string, section: JumpSection = 'elements'): void {
|
||||
@@ -299,7 +371,7 @@ export class ThemeSettingsComponent {
|
||||
|
||||
if (section === 'layout') {
|
||||
this.activeWorkspace.set('layout');
|
||||
} else if (section === 'animations') {
|
||||
} else if (section === 'animations' || section === 'css') {
|
||||
this.activeWorkspace.set('editor');
|
||||
} else {
|
||||
this.activeWorkspace.set('inspector');
|
||||
@@ -331,6 +403,9 @@ export class ThemeSettingsComponent {
|
||||
const value = getSuggestedFieldDefault(property, this.animationKeys());
|
||||
|
||||
this.theme.setElementStyle(this.selectedElementKey(), property, value);
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonProperty('elements', this.selectedElementKey(), property);
|
||||
}
|
||||
|
||||
addStarterAnimation(): void {
|
||||
@@ -355,37 +430,200 @@ export class ThemeSettingsComponent {
|
||||
|
||||
jumpToLayout(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonAnchor('layout', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToStyles(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonAnchor('elements', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToAnimation(animationKey: string): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonAnchor('animations', animationKey);
|
||||
}
|
||||
|
||||
jumpToCss(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('cssOnly');
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
isMounted(entry: ThemeRegistryEntry): boolean {
|
||||
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private focusEditor(): void {
|
||||
this.withEditorReady((editor) => {
|
||||
editor.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private withEditorReady(action: (editor: ThemeJsonCodeEditorComponent) => void, attempts = 8): void {
|
||||
queueMicrotask(() => {
|
||||
this.editorRef()?.focus();
|
||||
const editor = this.editorRef();
|
||||
|
||||
if (editor) {
|
||||
action(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempts <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.withEditorReady(action, attempts - 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private syncCssOnlyTextFromTheme(): void {
|
||||
this.cssOnlyText.set(this.activeTheme().css || this.draftTheme().css);
|
||||
}
|
||||
|
||||
private getExportThemeText(): string | null {
|
||||
if (this.activeEditorTab() === 'cssOnly') {
|
||||
return this.theme.buildDraftTextWithCss(this.cssOnlyText());
|
||||
}
|
||||
|
||||
if (!this.draftIsValid()) {
|
||||
this.theme.announceStatus('Fix JSON errors before exporting the theme.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.draftText();
|
||||
}
|
||||
|
||||
private async loadThemeFile(file: File): Promise<void> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.css')) {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('cssOnly');
|
||||
this.cssOnlyText.set(text);
|
||||
this.theme.announceStatus(`${file.name} loaded into the CSS editor.`);
|
||||
this.focusEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = this.theme.loadThemeText(text, 'draft', `${file.name} imported into the draft editor.`, 'imported theme file');
|
||||
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
this.focusEditor();
|
||||
} catch {
|
||||
this.theme.announceStatus(`Unable to import ${file.name}.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveTextAsFile(fileName: string, text: string): Promise<boolean> {
|
||||
const electronApi = getElectronApi();
|
||||
|
||||
if (electronApi) {
|
||||
const result = await electronApi.saveFileAs(fileName, this.encodeBase64(text));
|
||||
|
||||
return result.saved;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private encodeBase64(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
|
||||
let binary = '';
|
||||
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
private sanitizeThemeFileName(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized.length > 0 ? normalized : 'theme';
|
||||
}
|
||||
|
||||
private openPickedElementInJson(key: string): void {
|
||||
const definition = this.registry.getDefinition(key);
|
||||
|
||||
if (!definition) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedElementKey.set(key);
|
||||
|
||||
if (definition.container) {
|
||||
this.selectedContainer.set(definition.container);
|
||||
}
|
||||
|
||||
if (this.draftIsValid()) {
|
||||
this.theme.ensureElementEntry(key);
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonElementEntry(key);
|
||||
}
|
||||
|
||||
private focusJsonElementEntry(key: string): void {
|
||||
this.withEditorReady((editor) => {
|
||||
let text = this.draftText();
|
||||
let anchorIndex = this.findAnchorIndex(text, 'elements', key);
|
||||
|
||||
if (anchorIndex === -1 && this.draftIsValid()) {
|
||||
this.theme.ensureElementEntry(key);
|
||||
text = this.draftText();
|
||||
anchorIndex = this.findAnchorIndex(text, 'elements', key);
|
||||
}
|
||||
|
||||
if (anchorIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const colonIndex = text.indexOf(':', anchorIndex);
|
||||
const objectStart = colonIndex === -1 ? -1 : text.indexOf('{', colonIndex);
|
||||
|
||||
if (objectStart === -1) {
|
||||
editor.focusRange(anchorIndex, Math.min(anchorIndex + key.length + 2, text.length));
|
||||
return;
|
||||
}
|
||||
|
||||
editor.focusRange(objectStart + 1, objectStart + 1);
|
||||
});
|
||||
}
|
||||
|
||||
private focusJsonAnchor(section: JumpSection, key: string): void {
|
||||
queueMicrotask(() => {
|
||||
const editor = this.editorRef();
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.withEditorReady((editor) => {
|
||||
let text = this.draftText();
|
||||
let anchorIndex = this.findAnchorIndex(text, section, key);
|
||||
|
||||
@@ -413,6 +651,94 @@ export class ThemeSettingsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private focusJsonProperty(section: JumpSection, key: string, property: string): void {
|
||||
this.withEditorReady((editor) => {
|
||||
const text = this.draftText();
|
||||
const keyIndex = this.findAnchorIndex(text, section, key);
|
||||
|
||||
if (keyIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const propertyIndex = text.indexOf(`"${property}"`, keyIndex);
|
||||
|
||||
if (propertyIndex === -1) {
|
||||
editor.focusRange(keyIndex, Math.min(keyIndex + key.length + 2, text.length));
|
||||
return;
|
||||
}
|
||||
|
||||
const valueRange = this.findJsonPropertyValueRange(text, propertyIndex);
|
||||
|
||||
editor.focusRange(valueRange.start, valueRange.end);
|
||||
});
|
||||
}
|
||||
|
||||
private findJsonPropertyValueRange(text: string, propertyIndex: number): { start: number; end: number } {
|
||||
const colonIndex = text.indexOf(':', propertyIndex);
|
||||
|
||||
if (colonIndex === -1) {
|
||||
return {
|
||||
start: propertyIndex,
|
||||
end: propertyIndex
|
||||
};
|
||||
}
|
||||
|
||||
let valueStart = colonIndex + 1;
|
||||
|
||||
while (valueStart < text.length && /\s/.test(text[valueStart])) {
|
||||
valueStart += 1;
|
||||
}
|
||||
|
||||
if (text[valueStart] === '"') {
|
||||
const stringContentStart = valueStart + 1;
|
||||
|
||||
let stringContentEnd = stringContentStart;
|
||||
|
||||
while (stringContentEnd < text.length) {
|
||||
if (text[stringContentEnd] === '"' && text[stringContentEnd - 1] !== '\\') {
|
||||
break;
|
||||
}
|
||||
|
||||
stringContentEnd += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
start: stringContentStart,
|
||||
end: stringContentEnd
|
||||
};
|
||||
}
|
||||
|
||||
let valueEnd = valueStart;
|
||||
|
||||
while (valueEnd < text.length && ![
|
||||
',',
|
||||
'\n',
|
||||
'}'
|
||||
].includes(text[valueEnd])) {
|
||||
valueEnd += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
start: valueStart,
|
||||
end: Math.max(valueStart, valueEnd)
|
||||
};
|
||||
}
|
||||
|
||||
private focusJsonSection(section: JumpSection): void {
|
||||
this.withEditorReady((editor) => {
|
||||
const text = this.draftText();
|
||||
const anchorIndex = text.indexOf(`"${section}"`);
|
||||
|
||||
if (anchorIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
editor.focusRange(anchorIndex, Math.min(anchorIndex + section.length + 2, text.length));
|
||||
});
|
||||
}
|
||||
|
||||
private async copyTextToClipboard(value: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user