Revert the automated member-ordering pass that broke Angular field init (TS2729) and disable that rule until a safe reorder strategy exists. Fix modal/confirm dialog i18n defaults via template fallbacks, search all active endpoints (including offline), register foreign rooms with actor owner IDs, sync profile display names from avatar summaries, and guard dm-chat when a private call converts to a group conversation. Co-authored-by: Cursor <cursoragent@cursor.com>
857 lines
24 KiB
TypeScript
857 lines
24 KiB
TypeScript
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<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 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<ThemeStudioWorkspace>('editor');
|
|
readonly activeEditorTab = signal<ThemeEditorTab>('json');
|
|
readonly cssOnlyText = signal('');
|
|
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 ? 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<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() {
|
|
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<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
|
|
? 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<void> {
|
|
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<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();
|
|
}
|
|
|
|
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<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(this.appI18n.instant('theme.status.importFailed', { fileName: 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 {
|
|
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<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(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);
|
|
}
|
|
}
|