feat: Theme engine
big changes
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
<div class="theme-grid-editor rounded-2xl border border-border bg-background/50 p-3">
|
||||
<div class="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground">{{ container().label }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ container().description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{{ container().columns }} cols x {{ container().rows }} rows
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
#canvasRef
|
||||
class="theme-grid-editor__frame relative overflow-hidden rounded-xl border border-border/80"
|
||||
[ngStyle]="frameStyle()"
|
||||
>
|
||||
<div class="theme-grid-editor__grid"></div>
|
||||
|
||||
@for (item of items(); track item.key) {
|
||||
<div
|
||||
class="theme-grid-editor__item absolute"
|
||||
[class.theme-grid-editor__item--selected]="selectedKey() === item.key"
|
||||
[class.theme-grid-editor__item--disabled]="disabled()"
|
||||
[ngStyle]="itemStyle(item)"
|
||||
(click)="selectItem(item.key)"
|
||||
(keydown.enter)="selectItem(item.key)"
|
||||
(keydown.space)="selectItem(item.key)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-label]="'Select ' + item.label"
|
||||
>
|
||||
<div
|
||||
class="theme-grid-editor__item-body"
|
||||
(pointerdown)="startMove($event, item)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ item.label }}</p>
|
||||
<p class="mt-1 line-clamp-2 text-[11px] leading-4 text-muted-foreground">{{ item.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full bg-background/80 px-2 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
|
||||
{{ item.grid.x }},{{ item.grid.y }} · {{ item.grid.w }}x{{ item.grid.h }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="theme-grid-editor__handle"
|
||||
(pointerdown)="startResize($event, item)"
|
||||
aria-label="Resize {{ item.label }}"
|
||||
></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (disabled()) {
|
||||
<div
|
||||
class="theme-grid-editor__disabled absolute inset-0 flex items-center justify-center rounded-xl bg-background/75 px-6 text-center text-sm text-muted-foreground backdrop-blur-sm"
|
||||
>
|
||||
Fix JSON validation errors to re-enable the grid editor.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.theme-grid-editor__frame {
|
||||
aspect-ratio: 16 / 9;
|
||||
background:
|
||||
radial-gradient(circle at top, hsl(var(--primary) / 0.08), transparent 45%),
|
||||
linear-gradient(180deg, hsl(var(--background) / 0.96), hsl(var(--card) / 0.98));
|
||||
}
|
||||
|
||||
.theme-grid-editor__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, hsl(var(--border) / 0.65) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(var(--border) / 0.65) 1px, transparent 1px);
|
||||
background-size:
|
||||
calc(100% / var(--theme-grid-columns)) calc(100% / var(--theme-grid-rows)),
|
||||
calc(100% / var(--theme-grid-columns)) calc(100% / var(--theme-grid-rows));
|
||||
}
|
||||
|
||||
.theme-grid-editor__item {
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item-body {
|
||||
height: 100%;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
linear-gradient(180deg, hsl(var(--card) / 0.96), hsl(var(--background) / 0.96)),
|
||||
radial-gradient(circle at top right, hsl(var(--primary) / 0.1), transparent 45%);
|
||||
box-shadow: 0 12px 30px rgb(0 0 0 / 10%);
|
||||
cursor: grab;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item:active .theme-grid-editor__item-body {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item--selected .theme-grid-editor__item-body {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--primary)),
|
||||
0 14px 34px hsl(var(--primary) / 0.18);
|
||||
}
|
||||
|
||||
.theme-grid-editor__item--disabled .theme-grid-editor__item-body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item:focus-visible .theme-grid-editor__item-body {
|
||||
box-shadow:
|
||||
0 0 0 2px hsl(var(--primary)),
|
||||
0 14px 34px hsl(var(--primary) / 0.18);
|
||||
}
|
||||
|
||||
.theme-grid-editor__handle {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
height: 0.95rem;
|
||||
width: 0.95rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--background));
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.theme-grid-editor__disabled {
|
||||
border: 1px dashed hsl(var(--border));
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import {
|
||||
ThemeGridEditorItem,
|
||||
ThemeGridRect,
|
||||
ThemeLayoutContainerDefinition
|
||||
} from '../../domain/theme.models';
|
||||
|
||||
type DragMode = 'move' | 'resize';
|
||||
|
||||
interface DragState {
|
||||
key: string;
|
||||
mode: DragMode;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startGrid: ThemeGridRect;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-grid-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './theme-grid-editor.component.html',
|
||||
styleUrl: './theme-grid-editor.component.scss'
|
||||
})
|
||||
export class ThemeGridEditorComponent {
|
||||
readonly container = input.required<ThemeLayoutContainerDefinition>();
|
||||
readonly items = input.required<ThemeGridEditorItem[]>();
|
||||
readonly selectedKey = input<string | null>(null);
|
||||
readonly disabled = input(false);
|
||||
|
||||
readonly itemChanged = output<{ key: string; grid: ThemeGridRect }>();
|
||||
readonly itemSelected = output<string>();
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private dragState: DragState | null = null;
|
||||
|
||||
readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef');
|
||||
readonly frameStyle = computed(() => ({
|
||||
'--theme-grid-columns': `${this.container().columns}`,
|
||||
'--theme-grid-rows': `${this.container().rows}`
|
||||
}));
|
||||
|
||||
itemStyle(item: ThemeGridEditorItem): Record<string, string> {
|
||||
const { columns, rows } = this.container();
|
||||
|
||||
return {
|
||||
left: `${(item.grid.x / columns) * 100}%`,
|
||||
top: `${(item.grid.y / rows) * 100}%`,
|
||||
width: `${(item.grid.w / columns) * 100}%`,
|
||||
height: `${(item.grid.h / rows) * 100}%`
|
||||
};
|
||||
}
|
||||
|
||||
selectItem(key: string): void {
|
||||
this.itemSelected.emit(key);
|
||||
}
|
||||
|
||||
startMove(event: PointerEvent, item: ThemeGridEditorItem): void {
|
||||
this.startDrag(event, item, 'move');
|
||||
}
|
||||
|
||||
startResize(event: PointerEvent, item: ThemeGridEditorItem): void {
|
||||
this.startDrag(event, item, 'resize');
|
||||
}
|
||||
|
||||
@HostListener('document:pointermove', ['$event'])
|
||||
onPointerMove(event: PointerEvent): void {
|
||||
if (!this.dragState || this.disabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect();
|
||||
const columnWidth = canvasRect.width / this.container().columns;
|
||||
const rowHeight = canvasRect.height / this.container().rows;
|
||||
const deltaColumns = Math.round((event.clientX - this.dragState.startClientX) / columnWidth);
|
||||
const deltaRows = Math.round((event.clientY - this.dragState.startClientY) / rowHeight);
|
||||
const nextGrid = { ...this.dragState.startGrid };
|
||||
|
||||
if (this.dragState.mode === 'move') {
|
||||
nextGrid.x = this.clamp(deltaColumns + this.dragState.startGrid.x, 0, this.container().columns - nextGrid.w);
|
||||
nextGrid.y = this.clamp(deltaRows + this.dragState.startGrid.y, 0, this.container().rows - nextGrid.h);
|
||||
} else {
|
||||
nextGrid.w = this.clamp(deltaColumns + this.dragState.startGrid.w, 1, this.container().columns - nextGrid.x);
|
||||
nextGrid.h = this.clamp(deltaRows + this.dragState.startGrid.h, 1, this.container().rows - nextGrid.y);
|
||||
}
|
||||
|
||||
this.itemChanged.emit({
|
||||
key: this.dragState.key,
|
||||
grid: nextGrid
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:pointerup')
|
||||
@HostListener('document:pointercancel')
|
||||
onPointerUp(): void {
|
||||
this.dragState = null;
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.dragState = null;
|
||||
}
|
||||
|
||||
private startDrag(event: PointerEvent, item: ThemeGridEditorItem, mode: DragMode): void {
|
||||
if (this.disabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.itemSelected.emit(item.key);
|
||||
this.dragState = {
|
||||
key: item.key,
|
||||
mode,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startGrid: { ...item.grid }
|
||||
};
|
||||
}
|
||||
|
||||
private clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { basicSetup } from 'codemirror';
|
||||
|
||||
const THEME_JSON_EDITOR_THEME = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#e7eef9'
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: "'IBM Plex Mono', 'JetBrains Mono', 'Fira Code', monospace",
|
||||
lineHeight: '1.55'
|
||||
},
|
||||
'.cm-content': {
|
||||
minHeight: '100%',
|
||||
padding: '1rem 0',
|
||||
caretColor: '#f8fafc'
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 1rem 0 0.5rem'
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: '#f8fafc'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
minHeight: '100%',
|
||||
borderRight: '1px solid #2f405c',
|
||||
backgroundColor: '#172033',
|
||||
color: '#7b8aa5'
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgb(148 163 184 / 0.08)'
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'rgb(148 163 184 / 0.12)',
|
||||
color: '#d7e2f2'
|
||||
},
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'rgb(96 165 250 / 0.22)'
|
||||
},
|
||||
'.cm-panels': {
|
||||
backgroundColor: '#111827',
|
||||
color: '#e5eefc',
|
||||
borderBottom: '1px solid #2f405c'
|
||||
},
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: 'rgb(250 204 21 / 0.18)',
|
||||
outline: '1px solid rgb(250 204 21 / 0.32)'
|
||||
},
|
||||
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||
backgroundColor: 'rgb(250 204 21 / 0.28)'
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
border: '1px solid #314158',
|
||||
backgroundColor: '#111827'
|
||||
},
|
||||
'.cm-tooltip-autocomplete ul li[aria-selected]': {
|
||||
backgroundColor: 'rgb(96 165 250 / 0.18)',
|
||||
color: '#f8fafc'
|
||||
}
|
||||
}, { dark: true });
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-json-code-editor',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div
|
||||
class="theme-json-code-editor"
|
||||
[style.minHeight]="editorMinHeight()"
|
||||
>
|
||||
<div
|
||||
#editorHostRef
|
||||
class="theme-json-code-editor__host"
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.theme-json-code-editor {
|
||||
overflow: hidden;
|
||||
border: 1px solid #2f405c;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(96 165 250 / 0.16), transparent 34%),
|
||||
linear-gradient(180deg, #172033, #0e1625);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(125 211 252 / 0.08),
|
||||
0 0 0 1px rgb(15 23 42 / 0.16);
|
||||
}
|
||||
|
||||
.theme-json-code-editor:focus-within {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(125 211 252 / 0.5),
|
||||
0 0 0 3px rgb(14 165 233 / 0.18);
|
||||
}
|
||||
|
||||
.theme-json-code-editor__host {
|
||||
min-height: inherit;
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
private readonly zone = inject(NgZone);
|
||||
|
||||
readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef');
|
||||
readonly value = input.required<string>();
|
||||
readonly fullscreen = input(false);
|
||||
readonly valueChange = output<string>();
|
||||
|
||||
readonly editorMinHeight = computed(() => this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem');
|
||||
|
||||
private editorView: EditorView | null = null;
|
||||
private isApplyingExternalValue = false;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const host = this.editorHostRef();
|
||||
|
||||
if (!host || this.editorView) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createEditor(host.nativeElement);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const nextValue = this.value();
|
||||
|
||||
if (!this.editorView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = this.editorView.state.doc.toString();
|
||||
|
||||
if (currentValue === nextValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isApplyingExternalValue = true;
|
||||
this.editorView.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: nextValue
|
||||
}
|
||||
});
|
||||
this.isApplyingExternalValue = false;
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.fullscreen();
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.editorView?.requestMeasure();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.editorView?.destroy();
|
||||
this.editorView = null;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.editorView?.focus();
|
||||
}
|
||||
|
||||
focusRange(from: number, to = from): void {
|
||||
if (!this.editorView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentLength = this.editorView.state.doc.length;
|
||||
const selectionStart = Math.max(0, Math.min(from, documentLength));
|
||||
const selectionEnd = Math.max(selectionStart, Math.min(to, documentLength));
|
||||
|
||||
this.editorView.dispatch({
|
||||
selection: EditorSelection.range(selectionStart, selectionEnd),
|
||||
effects: EditorView.scrollIntoView(selectionStart, { y: 'center' })
|
||||
});
|
||||
this.editorView.focus();
|
||||
}
|
||||
|
||||
private createEditor(host: HTMLDivElement): void {
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.editorView = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: this.value(),
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
indentUnit.of(' '),
|
||||
json(),
|
||||
oneDark,
|
||||
THEME_JSON_EDITOR_THEME,
|
||||
EditorState.tabSize.of(2),
|
||||
EditorView.contentAttributes.of({
|
||||
spellcheck: 'false',
|
||||
autocapitalize: 'off',
|
||||
autocorrect: 'off',
|
||||
'aria-label': 'Theme JSON editor'
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged || this.isApplyingExternalValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = update.state.doc.toString();
|
||||
|
||||
this.zone.run(() => {
|
||||
this.valueChange.emit(nextValue);
|
||||
});
|
||||
})
|
||||
]
|
||||
}),
|
||||
parent: host
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
<div
|
||||
class="theme-settings flex min-h-0 w-full flex-col space-y-4"
|
||||
[class.min-h-full]="isFullscreen()"
|
||||
[class.p-4]="isFullscreen()"
|
||||
[class.theme-settings--fullscreen]="isFullscreen()"
|
||||
>
|
||||
<section class="theme-studio-card p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Theme Studio</p>
|
||||
<h4 class="mt-1 text-xl font-semibold text-foreground">{{ draftTheme().meta.name }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="startPicker()"
|
||||
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Pick UI Element
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="formatDraft()"
|
||||
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Format JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="copyLlmThemeGuide()"
|
||||
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Copy LLM Guide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="applyDraft()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="inline-flex items-center rounded-full bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Apply Draft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="restoreDefaultTheme()"
|
||||
class="inline-flex items-center rounded-full border border-destructive/25 bg-destructive/10 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/15"
|
||||
>
|
||||
Restore Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (llmGuideCopyMessage()) {
|
||||
<div class="mt-3 inline-flex rounded-full border border-emerald-500/20 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||
{{ llmGuideCopyMessage() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="theme-settings__hero-grid mt-4">
|
||||
<div class="theme-settings__hero-stat">
|
||||
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--compact">
|
||||
<label class="theme-settings__workspace-selector-label">Workspace</label>
|
||||
<select
|
||||
class="theme-settings__workspace-select"
|
||||
[value]="activeWorkspace()"
|
||||
(change)="onWorkspaceSelect($event)"
|
||||
>
|
||||
@for (workspace of workspaceTabs; track workspace.key) {
|
||||
<option [value]="workspace.key">{{ workspace.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-settings__hero-stat">
|
||||
<span class="theme-settings__hero-label">Regions</span>
|
||||
<strong class="theme-settings__hero-value">{{ mountedEntryCount() }}</strong>
|
||||
</div>
|
||||
<div class="theme-settings__hero-stat">
|
||||
<span class="theme-settings__hero-label">Draft</span>
|
||||
<strong
|
||||
class="theme-settings__hero-value"
|
||||
[class.text-amber-700]="isDraftDirty()"
|
||||
[class.text-emerald-700]="!isDraftDirty()"
|
||||
>
|
||||
{{ isDraftDirty() ? 'Unsaved changes' : 'In sync' }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (statusMessage()) {
|
||||
<div class="mt-3 rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
{{ statusMessage() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!draftIsValid()) {
|
||||
<div class="mt-3 rounded-2xl border border-destructive/30 bg-destructive/8 p-4">
|
||||
<p class="text-sm font-semibold text-destructive">The draft is invalid. The last working theme is still active.</p>
|
||||
<ul class="mt-2 space-y-1 text-sm text-destructive/90">
|
||||
@for (error of draftErrors(); track error) {
|
||||
<li>{{ error }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="theme-settings__workspace min-h-0 flex-1">
|
||||
<aside class="theme-settings__sidebar">
|
||||
@if (savedThemesAvailable()) {
|
||||
<section class="theme-studio-card p-3.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-foreground">Saved Themes</p>
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{{ savedThemesBusy() ? 'Syncing' : savedThemes().length + ' saved' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="saveDraftAsNewTheme()"
|
||||
[disabled]="!draftIsValid() || savedThemesBusy()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Save New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="saveDraftToSelectedTheme()"
|
||||
[disabled]="!draftIsValid() || !selectedSavedTheme() || savedThemesBusy()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Save Selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="useSelectedSavedTheme()"
|
||||
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="editSelectedSavedTheme()"
|
||||
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="removeSelectedSavedTheme()"
|
||||
[disabled]="!selectedSavedTheme() || (savedThemesBusy() && savedThemes().length === 0)"
|
||||
class="rounded-full border border-destructive/25 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="refreshSavedThemes()"
|
||||
[disabled]="savedThemesBusy() && savedThemes().length === 0"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (savedThemes().length > 0) {
|
||||
<div class="theme-settings__saved-theme-list mt-4">
|
||||
@for (savedTheme of savedThemes(); track savedTheme.fileName) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectSavedTheme(savedTheme.fileName)"
|
||||
class="theme-settings__saved-theme-button"
|
||||
[class.theme-settings__saved-theme-button--active]="selectedSavedTheme()?.fileName === savedTheme.fileName"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground">{{ savedTheme.themeName }}</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-muted-foreground">{{ savedTheme.fileName }}</p>
|
||||
</div>
|
||||
|
||||
@if (savedTheme.isValid) {
|
||||
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">Ready</span>
|
||||
} @else {
|
||||
<span class="rounded-full bg-destructive/10 px-2 py-0.5 text-[10px] font-medium text-destructive">Invalid</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (savedTheme.version) {
|
||||
<p class="mt-2 text-[11px] font-medium text-muted-foreground">v{{ savedTheme.version }}</p>
|
||||
}
|
||||
|
||||
@if (savedTheme.description) {
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">{{ savedTheme.description }}</p>
|
||||
}
|
||||
|
||||
@if (savedTheme.error) {
|
||||
<p class="mt-2 text-xs leading-5 text-destructive">{{ savedTheme.error }}</p>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-4 rounded-2xl border border-dashed border-border bg-background/60 px-4 py-5 text-sm text-muted-foreground">
|
||||
Save the current draft to create your first reusable Electron theme.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (savedThemesPath()) {
|
||||
<p class="mt-3 font-mono text-[11px] leading-5 text-muted-foreground">{{ savedThemesPath() }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="theme-studio-card p-3.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-foreground">Explorer</p>
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{{ filteredEntries().length }} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<input
|
||||
type="text"
|
||||
[value]="explorerQuery()"
|
||||
(input)="onExplorerQueryInput($event)"
|
||||
placeholder="Search theme keys"
|
||||
class="theme-settings__search-input mt-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="theme-settings__entry-list mt-4">
|
||||
@for (entry of filteredEntries(); track entry.key) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectThemeEntry(entry.key)"
|
||||
class="theme-settings__entry-button"
|
||||
[class.theme-settings__entry-button--active]="selectedElementKey() === entry.key"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-semibold text-foreground">{{ entry.label }}</span>
|
||||
@if (isMounted(entry)) {
|
||||
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">Mounted</span>
|
||||
}
|
||||
</div>
|
||||
<span class="mt-1 block font-mono text-[11px] text-muted-foreground">{{ entry.key }}</span>
|
||||
<span class="mt-2 block text-xs leading-5 text-muted-foreground">{{ entry.description }}</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-5 text-sm text-muted-foreground">
|
||||
No registered theme keys match this filter.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="theme-settings__main">
|
||||
<section class="theme-studio-card p-3.5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
@if (selectedElement()) {
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-base font-semibold text-foreground">{{ selectedElement()!.label }}</span>
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ selectedElement()!.key }}</span>
|
||||
@if (selectedElement()!.container) {
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{{
|
||||
selectedElement()!.container
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ selectedElement()!.description }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedElement()) {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="jumpToStyles()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Open styles in JSON
|
||||
</button>
|
||||
@if (selectedElement()!.layoutEditable) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="jumpToLayout()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Open layout in JSON
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedElementCapabilities().length > 0) {
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@for (capability of selectedElementCapabilities(); track capability) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">{{ capability }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (activeWorkspace() === 'editor') {
|
||||
<section class="theme-studio-card theme-settings__editor-card p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3">
|
||||
<p class="text-sm font-semibold text-foreground">Theme JSON</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1">{{ draftLineCount() }} lines</span>
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1">{{ draftCharacterCount() }} chars</span>
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
|
||||
<span class="rounded-full bg-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-settings__editor-panel pt-3">
|
||||
<app-theme-json-code-editor
|
||||
#jsonEditorRef
|
||||
[value]="draftText()"
|
||||
[fullscreen]="isFullscreen()"
|
||||
(valueChange)="onDraftEditorValueChange($event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (activeWorkspace() === 'inspector') {
|
||||
<div class="space-y-6">
|
||||
<section class="theme-studio-card p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-foreground">Selection</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="startPicker()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Pick live element
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (selectedElement()) {
|
||||
<div class="mt-4 rounded-2xl border border-border/80 bg-background/65 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-base font-semibold text-foreground">{{ selectedElement()!.label }}</p>
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ selectedElement()!.key }}</span>
|
||||
@if (isMounted(selectedElement()!)) {
|
||||
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-700">Mounted now</span>
|
||||
}
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ selectedElement()!.description }}</p>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="theme-studio-card p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-foreground">Schema Hints</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="addStarterAnimation()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Add fade animation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 xl:grid-cols-2">
|
||||
@for (field of visiblePropertyHints(); track field.key) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="applySuggestedProperty(field.key)"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="rounded-2xl border border-border/80 bg-background/65 p-3 text-left transition-colors hover:bg-secondary/45 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-semibold text-foreground">{{ field.key }}</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ field.description }}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">{{ field.type }}</span>
|
||||
</div>
|
||||
<div class="mt-3 inline-flex rounded-full bg-primary/10 px-2.5 py-1 font-mono text-[11px] text-primary">
|
||||
{{ field.example }}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="theme-studio-card p-5">
|
||||
<p class="text-sm font-semibold text-foreground">Animation Keys</p>
|
||||
|
||||
@if (animationKeys().length > 0) {
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@for (animationKey of animationKeys(); track animationKey) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="jumpToAnimation(animationKey)"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 font-mono text-[11px] text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
{{ animationKey }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-4 rounded-2xl border border-dashed border-border bg-background/60 px-4 py-5 text-sm text-muted-foreground">
|
||||
No custom animation keys yet.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-border/80 bg-background/65 p-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (field of THEME_ANIMATION_FIELDS; track field.key) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 font-mono text-[11px] text-foreground/80">{{ field.key }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeWorkspace() === 'layout') {
|
||||
<section class="theme-studio-card p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<p class="text-sm font-semibold text-foreground">Layout Grid</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (container of layoutContainers; track container.key) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectContainer(container.key)"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
[class.bg-primary/10]="selectedContainer() === container.key"
|
||||
[class.border-primary/40]="selectedContainer() === container.key"
|
||||
>
|
||||
{{ container.label }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="resetSelectedContainer()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Reset Container
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<app-theme-grid-editor
|
||||
[container]="selectedContainer() === 'appShell' ? layoutContainers[0] : layoutContainers[1]"
|
||||
[items]="selectedContainerItems()"
|
||||
[selectedKey]="selectedElementKey()"
|
||||
[disabled]="!draftIsValid()"
|
||||
(itemChanged)="handleGridChange($event)"
|
||||
(itemSelected)="handleGridSelection($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (selectedElementGrid()) {
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">x</p>
|
||||
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.x }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">y</p>
|
||||
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.y }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">w</p>
|
||||
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.w }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">h</p>
|
||||
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.h }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.theme-settings {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector--compact {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector-label {
|
||||
font-size: 0.69rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.theme-settings__workspace-select {
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.85rem;
|
||||
background: hsl(var(--background) / 0.82);
|
||||
padding: 0.65rem 0.8rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-select:focus {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector--compact .theme-settings__workspace-select {
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.theme-settings__editor-card {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-settings__editor-panel {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-settings__saved-theme-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.theme-settings__saved-theme-button {
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--background) / 0.65);
|
||||
padding: 0.85rem;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.theme-settings__saved-theme-button:hover {
|
||||
background: hsl(var(--secondary) / 0.45);
|
||||
}
|
||||
|
||||
.theme-settings__saved-theme-button--active {
|
||||
border-color: hsl(var(--primary) / 0.38);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.theme-json-editor-panel__header {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-json-editor-panel__title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.theme-json-editor-panel__caption {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__editor-panel app-theme-json-code-editor {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
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/theme.models';
|
||||
import {
|
||||
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
|
||||
THEME_ELEMENT_STYLE_FIELDS,
|
||||
createAnimationStarterDefinition,
|
||||
getSuggestedFieldDefault
|
||||
} from '../../domain/theme.schema';
|
||||
import { ElementPickerService } from '../../application/element-picker.service';
|
||||
import { LayoutSyncService } from '../../application/layout-sync.service';
|
||||
import { ThemeLibraryService } from '../../application/theme-library.service';
|
||||
import { ThemeRegistryService } from '../../application/theme-registry.service';
|
||||
import { ThemeService } from '../../application/theme.service';
|
||||
import { THEME_LLM_GUIDE } from '../../domain/theme-llm-guide';
|
||||
import { ThemeGridEditorComponent } from './theme-grid-editor.component';
|
||||
import { ThemeJsonCodeEditorComponent } from './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 {
|
||||
private readonly modal = inject(SettingsModalService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly themeLibrary = inject(ThemeLibraryService);
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
private readonly picker = inject(ElementPickerService);
|
||||
private 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: ReadonlyArray<{ 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);
|
||||
}
|
||||
}
|
||||
248
toju-app/src/app/domains/theme/feature/theme-node.directive.ts
Normal file
248
toju-app/src/app/domains/theme/feature/theme-node.directive.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
effect,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
|
||||
import { ExternalLinkService } from '../../../core/platform';
|
||||
import { ElementPickerService } from '../application/element-picker.service';
|
||||
import { ThemeRegistryService } from '../application/theme-registry.service';
|
||||
import { ThemeService } from '../application/theme.service';
|
||||
|
||||
function looksLikeImageReference(value: string): boolean {
|
||||
return value.startsWith('url(')
|
||||
|| value.startsWith('http://')
|
||||
|| value.startsWith('https://')
|
||||
|| value.startsWith('/')
|
||||
|| value.startsWith('./');
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[appThemeNode]',
|
||||
standalone: true
|
||||
})
|
||||
export class ThemeNodeDirective {
|
||||
readonly themeKey = input.required<string>({ alias: 'appThemeNode' });
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
private readonly picker = inject(ElementPickerService);
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
|
||||
private appliedStyleKeys = new Set<string>();
|
||||
private appliedClasses = new Set<string>();
|
||||
private originalTextContent = new WeakMap<HTMLElement, string>();
|
||||
private registeredKey: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const key = this.themeKey();
|
||||
const definition = this.registry.getDefinition(key);
|
||||
const host = this.host.nativeElement;
|
||||
|
||||
host.dataset['themeKey'] = key;
|
||||
host.dataset['themeLabel'] = definition?.label ?? key;
|
||||
|
||||
if (this.registeredKey && this.registeredKey !== key) {
|
||||
this.registry.unregisterHost(this.registeredKey, host);
|
||||
}
|
||||
|
||||
if (this.registeredKey !== key) {
|
||||
this.registry.registerHost(key, host);
|
||||
this.registeredKey = key;
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.applyThemeStyles();
|
||||
this.applyAnimationClasses();
|
||||
this.applyTextOverride();
|
||||
this.applyIconOverride();
|
||||
this.applyLinkState();
|
||||
this.applyPickerState();
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('keydown.enter', ['$event'])
|
||||
onEnterKey(event: Event): void {
|
||||
this.openConfiguredLink(event);
|
||||
}
|
||||
|
||||
@HostListener('click', ['$event'])
|
||||
onClick(event: MouseEvent): void {
|
||||
this.openConfiguredLink(event);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.registeredKey) {
|
||||
this.registry.unregisterHost(this.registeredKey, this.host.nativeElement);
|
||||
this.registeredKey = null;
|
||||
}
|
||||
|
||||
this.clearAppliedStyles();
|
||||
this.clearAppliedClasses();
|
||||
this.restoreTextTarget();
|
||||
this.resetIconTarget();
|
||||
}
|
||||
|
||||
private applyThemeStyles(): void {
|
||||
const styles = this.theme.getHostStyles(this.themeKey());
|
||||
|
||||
this.clearAppliedStyles();
|
||||
|
||||
for (const [styleKey, styleValue] of Object.entries(styles)) {
|
||||
this.host.nativeElement.style.setProperty(styleKey, styleValue);
|
||||
this.appliedStyleKeys.add(styleKey);
|
||||
}
|
||||
}
|
||||
|
||||
private applyAnimationClasses(): void {
|
||||
this.clearAppliedClasses();
|
||||
|
||||
const animationClass = this.theme.getAnimationClass(this.themeKey());
|
||||
|
||||
if (!animationClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.host.nativeElement.classList.add(animationClass);
|
||||
this.appliedClasses.add(animationClass);
|
||||
}
|
||||
|
||||
private applyTextOverride(): void {
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const textTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="text"]');
|
||||
|
||||
if (!definition?.supportsTextOverride || !textTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.originalTextContent.has(textTarget)) {
|
||||
this.originalTextContent.set(textTarget, textTarget.textContent ?? '');
|
||||
}
|
||||
|
||||
textTarget.textContent = this.theme.getTextOverride(this.themeKey()) ?? this.originalTextContent.get(textTarget) ?? '';
|
||||
}
|
||||
|
||||
private applyIconOverride(): void {
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const iconTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="icon"]');
|
||||
const iconValue = this.theme.getIcon(this.themeKey());
|
||||
|
||||
if (!definition?.supportsIcon || !iconTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!iconValue) {
|
||||
this.resetIconTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
iconTarget.dataset['themeVisible'] = 'true';
|
||||
|
||||
if (looksLikeImageReference(iconValue)) {
|
||||
const imageReference = iconValue.startsWith('url(')
|
||||
? iconValue
|
||||
: `url('${iconValue}')`;
|
||||
|
||||
iconTarget.style.backgroundImage = imageReference;
|
||||
iconTarget.textContent = '';
|
||||
} else {
|
||||
iconTarget.style.backgroundImage = 'none';
|
||||
iconTarget.textContent = iconValue.slice(0, 2).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
private applyLinkState(): void {
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const link = this.theme.getLink(this.themeKey());
|
||||
const supportsLink = definition?.supportsLink && !!link;
|
||||
|
||||
this.host.nativeElement.dataset['themeLinked'] = supportsLink ? 'true' : 'false';
|
||||
|
||||
if (!supportsLink) {
|
||||
this.host.nativeElement.removeAttribute('tabindex');
|
||||
this.host.nativeElement.removeAttribute('role');
|
||||
return;
|
||||
}
|
||||
|
||||
this.host.nativeElement.setAttribute('tabindex', '0');
|
||||
this.host.nativeElement.setAttribute('role', 'link');
|
||||
}
|
||||
|
||||
private applyPickerState(): void {
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const isHovered = this.picker.hoveredKey() === this.themeKey();
|
||||
const isSelected = this.picker.selectedKey() === this.themeKey();
|
||||
const isActive = this.picker.isPicking() && !!definition?.pickerVisible;
|
||||
|
||||
this.host.nativeElement.dataset['themePickerActive'] = isActive ? 'true' : 'false';
|
||||
this.host.nativeElement.dataset['themePickerHovered'] = isHovered ? 'true' : 'false';
|
||||
this.host.nativeElement.dataset['themePickerSelected'] = isSelected ? 'true' : 'false';
|
||||
}
|
||||
|
||||
private openConfiguredLink(event: Event): void {
|
||||
if (this.picker.isPicking()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const link = this.theme.getLink(this.themeKey());
|
||||
|
||||
if (!definition?.supportsLink || !link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof Element && target.closest('button, input, textarea, select, a, [role="button"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.externalLinks.open(link);
|
||||
}
|
||||
|
||||
private clearAppliedStyles(): void {
|
||||
for (const styleKey of this.appliedStyleKeys) {
|
||||
this.host.nativeElement.style.removeProperty(styleKey);
|
||||
}
|
||||
|
||||
this.appliedStyleKeys.clear();
|
||||
}
|
||||
|
||||
private clearAppliedClasses(): void {
|
||||
for (const className of this.appliedClasses) {
|
||||
this.host.nativeElement.classList.remove(className);
|
||||
}
|
||||
|
||||
this.appliedClasses.clear();
|
||||
}
|
||||
|
||||
private restoreTextTarget(): void {
|
||||
const textTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="text"]');
|
||||
|
||||
if (!textTarget || !this.originalTextContent.has(textTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
textTarget.textContent = this.originalTextContent.get(textTarget) ?? '';
|
||||
}
|
||||
|
||||
private resetIconTarget(): void {
|
||||
const iconTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="icon"]');
|
||||
|
||||
if (!iconTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
iconTarget.dataset['themeVisible'] = 'false';
|
||||
iconTarget.style.backgroundImage = 'none';
|
||||
iconTarget.textContent = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { ElementPickerService } from '../application/element-picker.service';
|
||||
import { ThemeRegistryService } from '../application/theme-registry.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-picker-overlay',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
@if (picker.isPicking()) {
|
||||
<div class="pointer-events-none fixed inset-x-0 bottom-4 z-[95] flex justify-center px-4">
|
||||
<div class="pointer-events-auto max-w-xl rounded-2xl border border-border bg-card/95 px-4 py-3 shadow-2xl backdrop-blur-xl">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Theme Picker Active</p>
|
||||
<p class="mt-1 text-sm text-foreground">
|
||||
Click a highlighted area to inspect its theme key.
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Hovering:
|
||||
<span class="font-medium text-foreground">{{ hoveredEntry()?.label || 'Move over a themeable region' }}</span>
|
||||
@if (hoveredEntry()) {
|
||||
<span class="ml-1 rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-foreground/80">{{ hoveredEntry()!.key }}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="cancel()"
|
||||
class="inline-flex items-center rounded-full border border-border bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ThemePickerOverlayComponent {
|
||||
readonly picker = inject(ElementPickerService);
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
|
||||
readonly hoveredEntry = computed(() => {
|
||||
return this.registry.getDefinition(this.picker.hoveredKey());
|
||||
});
|
||||
|
||||
cancel(): void {
|
||||
this.picker.cancel();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user