import { Component, computed, effect, inject, signal, viewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SettingsModalService } from '../../../../../core/services/settings-modal.service'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../core/i18n'; import { getElectronApi } from '../../../../../core/platform/electron/get-electron-api'; import { ThemeContainerKey, ThemeElementStyleProperty, ThemeRegistryEntry } from '../../../domain/models/theme.model'; import { THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS, THEME_ELEMENT_STYLE_FIELDS, createAnimationStarterDefinition, getSuggestedFieldDefault } from '../../../domain/logic/theme-schema.logic'; import { ElementPickerService } from '../../../application/services/element-picker.service'; import { LayoutSyncService } from '../../../application/services/layout-sync.service'; import { ThemeLibraryService } from '../../../application/services/theme-library.service'; import { ThemeRegistryService } from '../../../application/services/theme-registry.service'; import { ThemeService } from '../../../application/services/theme.service'; import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.constants'; 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' | 'css'; type ThemeEditorTab = 'json' | 'cssOnly'; type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout'; @Component({ selector: 'app-theme-settings', standalone: true, imports: [ CommonModule, ThemeGridEditorComponent, ThemeJsonCodeEditorComponent, ...APP_TRANSLATE_IMPORTS ], templateUrl: './theme-settings.component.html', styleUrl: './theme-settings.component.scss' }) export class ThemeSettingsComponent { readonly modal = inject(SettingsModalService); readonly theme = inject(ThemeService); readonly themeLibrary = inject(ThemeLibraryService); readonly registry = inject(ThemeRegistryService); readonly picker = inject(ElementPickerService); readonly layoutSync = inject(LayoutSyncService); private readonly appI18n = inject(AppI18nService); readonly editorRef = viewChild('jsonEditorRef'); readonly draftText = this.theme.draftText; readonly draftErrors = this.theme.draftErrors; readonly draftIsValid = this.theme.draftIsValid; readonly statusMessage = this.theme.statusMessage; readonly isDraftDirty = this.theme.isDraftDirty; readonly isFullscreen = this.modal.themeStudioFullscreen; readonly activeTheme = this.theme.activeTheme; readonly builtInPresets = this.theme.builtInPresets; readonly draftTheme = this.theme.draftTheme; readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS; readonly animationKeys = this.theme.knownAnimationClasses; readonly layoutContainers = this.layoutSync.containers(); readonly themeEntries = this.registry.entries(); readonly workspaceTabs = computed(() => [ { key: 'editor' as const, label: this.appI18n.instant('theme.studio.workspaces.editor.label'), description: this.appI18n.instant('theme.studio.workspaces.editor.description') }, { key: 'inspector' as const, label: this.appI18n.instant('theme.studio.workspaces.inspector.label'), description: this.appI18n.instant('theme.studio.workspaces.inspector.description') }, { key: 'layout' as const, label: this.appI18n.instant('theme.studio.workspaces.layout.label'), description: this.appI18n.instant('theme.studio.workspaces.layout.description') } ]); readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts()); readonly activeWorkspace = signal('editor'); readonly activeEditorTab = signal('json'); readonly cssOnlyText = signal(''); readonly explorerQuery = signal(''); readonly selectedContainer = signal('roomLayout'); readonly selectedElementKey = signal('chatRoomMainPanel'); readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey())); readonly selectedElementCapabilities = computed(() => { const selected = this.selectedElement(); if (!selected) { return [] as string[]; } return [ selected.layoutEditable ? this.appI18n.instant('theme.studio.capabilities.layoutEditable') : null, selected.supportsTextOverride ? this.appI18n.instant('theme.studio.capabilities.textOverride') : null, selected.supportsLink ? this.appI18n.instant('theme.studio.capabilities.safeExternalLink') : null, selected.supportsIcon ? this.appI18n.instant('theme.studio.capabilities.iconSlot') : null ].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 localizedFilteredEntries = computed(() => this.filteredEntries().map((entry) => this.localizeRegistryEntry(entry)) ); readonly localizedSelectedElement = computed(() => { const selected = this.selectedElement(); return selected ? this.localizeRegistryEntry(selected) : null; }); readonly visiblePropertyHints = computed(() => { const selected = this.selectedElement(); return THEME_ELEMENT_STYLE_FIELDS.filter((field) => { if (field.key === 'textOverride' && !selected?.supportsTextOverride) { return false; } if (field.key === 'link' && !selected?.supportsLink) { return false; } if (field.key === 'icon' && !selected?.supportsIcon) { return false; } return true; }); }); readonly mountedEntries = computed(() => { return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable); }); readonly filteredEntries = computed(() => { const query = this.explorerQuery().trim() .toLowerCase(); if (!query) { return this.mountedEntries(); } return this.mountedEntries().filter((entry) => { const haystack = `${entry.label} ${entry.key} ${entry.description} ${entry.category}`.toLowerCase(); return haystack.includes(query); }); }); readonly draftLineCount = computed(() => this.draftText().split('\n').length); readonly draftCharacterCount = computed(() => this.draftText().length); readonly draftErrorCount = computed(() => this.draftErrors().length); readonly mountedEntryCount = computed(() => this.mountedEntries().length); readonly llmGuideCopyMessage = signal(null); readonly savedThemesAvailable = this.themeLibrary.isAvailable; readonly savedThemes = this.themeLibrary.entries; readonly savedThemesBusy = this.themeLibrary.isBusy; readonly savedThemesPath = this.themeLibrary.savedThemesPath; readonly selectedSavedTheme = this.themeLibrary.selectedEntry; private llmGuideCopyTimeoutId: ReturnType | null = null; constructor() { this.syncCssOnlyTextFromTheme(); if (this.savedThemesAvailable()) { void this.themeLibrary.refresh(); } effect(() => { const pickedKey = this.picker.selectedKey(); if (!pickedKey) { return; } this.openPickedElementInJson(pickedKey); this.picker.clearSelection(); }); effect(() => { if (this.activeEditorTab() !== 'json') { return; } this.cssOnlyText.set(this.draftTheme().css); }); effect(() => { if (!this.isFullscreen()) { return; } queueMicrotask(() => { this.focusEditor(); }); }); } onDraftEditorValueChange(value: string): void { 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); if (workspace === 'layout') { const selected = this.selectedElement(); if (selected?.container) { this.selectedContainer.set(selected.container); } } if (workspace === 'editor') { this.activeEditorTab.set('json'); this.focusEditor(); } } onWorkspaceSelect(event: Event): void { const select = event.target as HTMLSelectElement; this.setWorkspace(select.value as ThemeStudioWorkspace); } layoutContainerLabel(key: string): string { return this.appI18n.instant(`theme.registry.${key}.label`); } onExplorerQueryInput(event: Event): void { const input = event.target as HTMLInputElement; this.explorerQuery.set(input.value); } formatDraft(): void { this.theme.formatDraft(); this.focusEditor(); } async exportThemeFile(): Promise { 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 ? this.appI18n.instant('theme.studio.exported', { fileName }) : this.appI18n.instant('theme.studio.exportCancelled')); } 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 { const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE); this.setLlmGuideCopyMessage(copied ? this.appI18n.instant('theme.studio.llmGuideCopied') : this.appI18n.instant('theme.studio.llmGuideManualCopy')); } startPicker(): void { this.picker.start('theme'); } selectSavedTheme(fileName: string): void { this.themeLibrary.select(fileName); } async refreshSavedThemes(): Promise { await this.themeLibrary.refresh(); } async saveDraftAsNewTheme(): Promise { await this.themeLibrary.saveDraftAsNewTheme(); } async saveDraftToSelectedTheme(): Promise { await this.themeLibrary.saveDraftToSelectedTheme(); } async useSelectedSavedTheme(): Promise { await this.themeLibrary.useSelectedTheme(); } async editSelectedSavedTheme(): Promise { const opened = await this.themeLibrary.openSelectedThemeInDraft(); if (!opened) { return; } this.setWorkspace('editor'); this.focusEditor(); } async removeSelectedSavedTheme(): Promise { const selectedSavedTheme = this.selectedSavedTheme(); if (!selectedSavedTheme) { return; } const confirmed = window.confirm(`Delete saved theme "${selectedSavedTheme.themeName}"?`); if (!confirmed) { return; } await this.themeLibrary.removeSelectedTheme(); } applyBuiltInPreset(name: string): void { this.theme.applyBuiltInPreset(name); this.activeWorkspace.set('editor'); this.activeEditorTab.set('json'); this.syncCssOnlyTextFromTheme(); this.focusEditor(); } restoreDefaultTheme(): void { this.theme.resetToDefault('button'); this.activeWorkspace.set('editor'); this.activeEditorTab.set('json'); this.selectedContainer.set('roomLayout'); this.selectedElementKey.set('chatRoomMainPanel'); this.focusEditor(); } selectThemeEntry(key: string, section: JumpSection = 'elements'): void { const definition = this.registry.getDefinition(key); if (!definition) { return; } if (section === 'layout') { this.activeWorkspace.set('layout'); } else if (section === 'animations' || section === 'css') { this.activeWorkspace.set('editor'); } else { this.activeWorkspace.set('inspector'); } this.selectedElementKey.set(key); if (definition.container) { this.selectedContainer.set(definition.container); } } selectContainer(containerKey: ThemeContainerKey): void { this.activeWorkspace.set('layout'); this.selectedContainer.set(containerKey); const matchingItem = this.layoutSync.itemsForContainer(containerKey)[0]; if (matchingItem) { this.selectedElementKey.set(matchingItem.key); } } applySuggestedProperty(property: ThemeElementStyleProperty): void { if (!this.draftIsValid()) { return; } 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 { if (!this.draftIsValid()) { return; } this.theme.setAnimation('theme-fade-in', createAnimationStarterDefinition()); } handleGridChange(event: { key: string; grid: { x: number; y: number; w: number; h: number } }): void { this.layoutSync.updateGrid(event.key, event.grid); } handleGridSelection(key: string): void { this.selectThemeEntry(key, 'layout'); } resetSelectedContainer(): void { this.layoutSync.resetContainer(this.selectedContainer()); } 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(() => { 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(this.appI18n.instant('theme.studio.fixJsonBeforeExport')); return null; } return this.draftText(); } private async loadThemeFile(file: File): Promise { 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(this.appI18n.instant('theme.status.importFailed', { fileName: file.name })); } } private async saveTextAsFile(fileName: string, text: string): Promise { 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 { this.withEditorReady((editor) => { let text = this.draftText(); let anchorIndex = this.findAnchorIndex(text, section, key); if (anchorIndex === -1 && this.draftIsValid()) { if (section === 'elements') { this.theme.ensureElementEntry(key); } else if (section === 'layout') { this.theme.ensureLayoutEntry(key); } else if (section === 'animations') { this.theme.setAnimation(key, createAnimationStarterDefinition(), false); } text = this.draftText(); anchorIndex = this.findAnchorIndex(text, section, key); } if (anchorIndex === -1) { editor.focus(); return; } const selectionEnd = Math.min(anchorIndex + key.length + 2, text.length); editor.focusRange(anchorIndex, selectionEnd); }); } 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 { if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(value); return true; } catch {} } const textarea = document.createElement('textarea'); textarea.value = value; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; textarea.style.pointerEvents = 'none'; document.body.appendChild(textarea); textarea.select(); try { const copied = document.execCommand('copy'); if (copied) { return true; } } catch { window.prompt(this.appI18n.instant('theme.studio.llmGuidePrompt'), value); return false; } finally { document.body.removeChild(textarea); } window.prompt(this.appI18n.instant('theme.studio.llmGuidePrompt'), value); return false; } private setLlmGuideCopyMessage(message: string): void { this.llmGuideCopyMessage.set(message); if (this.llmGuideCopyTimeoutId) { clearTimeout(this.llmGuideCopyTimeoutId); } this.llmGuideCopyTimeoutId = setTimeout(() => { this.llmGuideCopyMessage.set(null); this.llmGuideCopyTimeoutId = null; }, 4000); } presetDisplayName(presetKey: string, fallbackName: string): string { const localized = this.appI18n.instant(`theme.presets.${presetKey}.name`); return localized === `theme.presets.${presetKey}.name` ? fallbackName : localized; } presetDescription(presetKey: string, fallbackDescription?: string): string | undefined { if (!fallbackDescription) { return undefined; } const localized = this.appI18n.instant(`theme.presets.${presetKey}.description`); return localized === `theme.presets.${presetKey}.description` ? fallbackDescription : localized; } isDefaultPresetName(name: string): boolean { return name === this.appI18n.instant('theme.presets.toju-default-dark-11.name'); } private localizeRegistryEntry(entry: ThemeRegistryEntry): ThemeRegistryEntry { return { ...entry, label: this.appI18n.instant(`theme.registry.${entry.key}.label`), description: this.appI18n.instant(`theme.registry.${entry.key}.description`) }; } private findAnchorIndex(text: string, section: JumpSection, key: string): number { const sectionAnchor = `"${section}": {`; const sectionIndex = text.indexOf(sectionAnchor); if (sectionIndex === -1) { return -1; } return text.indexOf(`"${key}"`, sectionIndex); } }