refactor: Clean lint errors and organise files
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
||||
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';
|
||||
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ThemeGridEditorComponent,
|
||||
ThemeJsonCodeEditorComponent
|
||||
],
|
||||
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);
|
||||
|
||||
readonly editorRef = viewChild<ThemeJsonCodeEditorComponent>('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 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: readonly { key: ThemeStudioWorkspace; label: string; description: string }[] = [
|
||||
{
|
||||
key: 'editor',
|
||||
label: 'JSON Editor',
|
||||
description: 'Edit the raw theme document in a fixed-contrast code view.'
|
||||
},
|
||||
{
|
||||
key: 'inspector',
|
||||
label: 'Element Inspector',
|
||||
description: 'Browse themeable regions, supported overrides, and starter values.'
|
||||
},
|
||||
{
|
||||
key: 'layout',
|
||||
label: 'Layout Studio',
|
||||
description: 'Move shells around the grid without hunting through JSON.'
|
||||
}
|
||||
];
|
||||
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
||||
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
||||
readonly explorerQuery = signal('');
|
||||
|
||||
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
||||
readonly selectedElementKey = signal<string>('chatRoomMainPanel');
|
||||
readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey()));
|
||||
readonly selectedElementCapabilities = computed(() => {
|
||||
const selected = this.selectedElement();
|
||||
|
||||
if (!selected) {
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
return [
|
||||
selected.layoutEditable ? 'Layout editable' : null,
|
||||
selected.supportsTextOverride ? 'Text override' : null,
|
||||
selected.supportsLink ? 'Safe external link' : null,
|
||||
selected.supportsIcon ? 'Icon slot' : null
|
||||
].filter((value): value is string => value !== null);
|
||||
});
|
||||
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
||||
readonly selectedElementGrid = computed(() => {
|
||||
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? 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<string | null>(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<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
if (this.savedThemesAvailable()) {
|
||||
void this.themeLibrary.refresh();
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const pickedKey = this.picker.selectedKey();
|
||||
|
||||
if (!pickedKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('inspector');
|
||||
this.selectThemeEntry(pickedKey, 'elements');
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.isFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.focusEditor();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onDraftEditorValueChange(value: string): void {
|
||||
this.theme.updateDraftText(value);
|
||||
}
|
||||
|
||||
applyDraft(): void {
|
||||
this.theme.applyDraft();
|
||||
}
|
||||
|
||||
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.focusEditor();
|
||||
}
|
||||
}
|
||||
|
||||
onWorkspaceSelect(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.setWorkspace(select.value as ThemeStudioWorkspace);
|
||||
}
|
||||
|
||||
onExplorerQueryInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.explorerQuery.set(input.value);
|
||||
}
|
||||
|
||||
formatDraft(): void {
|
||||
this.theme.formatDraft();
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
async copyLlmThemeGuide(): Promise<void> {
|
||||
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
|
||||
|
||||
this.setLlmGuideCopyMessage(copied
|
||||
? 'LLM guide copied.'
|
||||
: 'Manual copy opened.');
|
||||
}
|
||||
|
||||
startPicker(): void {
|
||||
this.picker.start('theme');
|
||||
}
|
||||
|
||||
selectSavedTheme(fileName: string): void {
|
||||
this.themeLibrary.select(fileName);
|
||||
}
|
||||
|
||||
async refreshSavedThemes(): Promise<void> {
|
||||
await this.themeLibrary.refresh();
|
||||
}
|
||||
|
||||
async saveDraftAsNewTheme(): Promise<void> {
|
||||
await this.themeLibrary.saveDraftAsNewTheme();
|
||||
}
|
||||
|
||||
async saveDraftToSelectedTheme(): Promise<void> {
|
||||
await this.themeLibrary.saveDraftToSelectedTheme();
|
||||
}
|
||||
|
||||
async useSelectedSavedTheme(): Promise<void> {
|
||||
await this.themeLibrary.useSelectedTheme();
|
||||
}
|
||||
|
||||
async editSelectedSavedTheme(): Promise<void> {
|
||||
const opened = await this.themeLibrary.openSelectedThemeInDraft();
|
||||
|
||||
if (!opened) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setWorkspace('editor');
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
async removeSelectedSavedTheme(): Promise<void> {
|
||||
const selectedSavedTheme = this.selectedSavedTheme();
|
||||
|
||||
if (!selectedSavedTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`Delete saved theme "${selectedSavedTheme.themeName}"?`);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.themeLibrary.removeSelectedTheme();
|
||||
}
|
||||
|
||||
restoreDefaultTheme(): void {
|
||||
this.theme.resetToDefault('button');
|
||||
this.activeWorkspace.set('editor');
|
||||
this.selectedContainer.set('roomLayout');
|
||||
this.selectedElementKey.set('chatRoomMainPanel');
|
||||
this.focusJsonAnchor('elements', 'chatRoomMainPanel');
|
||||
}
|
||||
|
||||
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') {
|
||||
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);
|
||||
}
|
||||
|
||||
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.focusJsonAnchor('layout', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToStyles(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.focusJsonAnchor('elements', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToAnimation(animationKey: string): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.focusJsonAnchor('animations', animationKey);
|
||||
}
|
||||
|
||||
isMounted(entry: ThemeRegistryEntry): boolean {
|
||||
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private focusEditor(): void {
|
||||
queueMicrotask(() => {
|
||||
this.editorRef()?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private focusJsonAnchor(section: JumpSection, key: string): void {
|
||||
queueMicrotask(() => {
|
||||
const editor = this.editorRef();
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 async copyTextToClipboard(value: string): Promise<boolean> {
|
||||
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('Copy this LLM theme guide', value);
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
window.prompt('Copy this LLM theme guide', 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user