feat: Theme studio v2
This commit is contained in:
@@ -15,11 +15,18 @@
|
||||
class="theme-grid-editor__frame relative overflow-hidden rounded-lg border border-border/80"
|
||||
[ngStyle]="frameStyle()"
|
||||
>
|
||||
<div class="theme-grid-editor__grid"></div>
|
||||
<div
|
||||
class="theme-grid-editor__grid"
|
||||
[ngStyle]="frameStyle()"
|
||||
>
|
||||
@for (cell of gridCells(); track cell) {
|
||||
<div class="theme-grid-editor__cell"></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@for (item of items(); track item.key) {
|
||||
<div
|
||||
class="theme-grid-editor__item absolute"
|
||||
class="theme-grid-editor__item"
|
||||
[class.theme-grid-editor__item--selected]="selectedKey() === item.key"
|
||||
[class.theme-grid-editor__item--disabled]="disabled()"
|
||||
[ngStyle]="itemStyle(item)"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
}
|
||||
|
||||
.theme-grid-editor__frame {
|
||||
display: grid;
|
||||
aspect-ratio: 16 / 9;
|
||||
background:
|
||||
radial-gradient(circle at top, hsl(var(--primary) / 0.05), transparent 45%),
|
||||
@@ -12,15 +13,18 @@
|
||||
.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));
|
||||
display: grid;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.theme-grid-editor__cell {
|
||||
border-top: 1px solid hsl(var(--border) / 0.65);
|
||||
border-left: 1px solid hsl(var(--border) / 0.65);
|
||||
}
|
||||
|
||||
.theme-grid-editor__item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,11 @@ export class ThemeGridEditorComponent {
|
||||
readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef');
|
||||
readonly frameStyle = computed(() => ({
|
||||
'--theme-grid-columns': `${this.container().columns}`,
|
||||
'--theme-grid-rows': `${this.container().rows}`
|
||||
'--theme-grid-rows': `${this.container().rows}`,
|
||||
gridTemplateColumns: this.container().templateColumns ?? `repeat(${this.container().columns}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: this.container().templateRows ?? `repeat(${this.container().rows}, minmax(0, 1fr))`
|
||||
}));
|
||||
readonly gridCells = computed(() => Array.from({ length: this.container().columns * this.container().rows }, (_, index) => index));
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private dragState: DragState | null = null;
|
||||
@@ -57,11 +60,8 @@ export class ThemeGridEditorComponent {
|
||||
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 deltaColumns = this.gridDelta('columns', this.dragState.startClientX, event.clientX);
|
||||
const deltaRows = this.gridDelta('rows', this.dragState.startClientY, event.clientY);
|
||||
const nextGrid = { ...this.dragState.startGrid };
|
||||
|
||||
if (this.dragState.mode === 'move') {
|
||||
@@ -90,13 +90,9 @@ export class ThemeGridEditorComponent {
|
||||
}
|
||||
|
||||
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}%`
|
||||
gridColumn: `${item.grid.x + 1} / span ${item.grid.w}`,
|
||||
gridRow: `${item.grid.y + 1} / span ${item.grid.h}`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,4 +128,61 @@ export class ThemeGridEditorComponent {
|
||||
private clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
private gridDelta(axis: 'columns' | 'rows', startClientPosition: number, currentClientPosition: number): number {
|
||||
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect();
|
||||
const linePositions = this.measureGridLines(axis, canvasRect);
|
||||
const startOffset = startClientPosition - (axis === 'columns' ? canvasRect.left : canvasRect.top);
|
||||
const currentOffset = currentClientPosition - (axis === 'columns' ? canvasRect.left : canvasRect.top);
|
||||
|
||||
return this.nearestGridLineIndex(currentOffset, linePositions) - this.nearestGridLineIndex(startOffset, linePositions);
|
||||
}
|
||||
|
||||
private measureGridLines(axis: 'columns' | 'rows', canvasRect: DOMRect): number[] {
|
||||
const canvas = this.canvasRef().nativeElement;
|
||||
const computedStyles = getComputedStyle(canvas);
|
||||
const expectedTracks = axis === 'columns' ? this.container().columns : this.container().rows;
|
||||
const availableSize = axis === 'columns' ? canvasRect.width : canvasRect.height;
|
||||
const template = axis === 'columns' ? computedStyles.gridTemplateColumns : computedStyles.gridTemplateRows;
|
||||
const trackSizes = template
|
||||
.split(/\s+/)
|
||||
.map((track) => Number.parseFloat(track))
|
||||
.filter((size) => Number.isFinite(size) && size > 0);
|
||||
|
||||
if (trackSizes.length !== expectedTracks) {
|
||||
return Array.from({ length: expectedTracks + 1 }, (_, index) => (availableSize / expectedTracks) * index);
|
||||
}
|
||||
|
||||
const totalTrackSize = trackSizes.reduce((sum, size) => sum + size, 0);
|
||||
const scale = totalTrackSize > 0 ? availableSize / totalTrackSize : 1;
|
||||
const linePositions = [0];
|
||||
|
||||
for (const trackSize of trackSizes) {
|
||||
const previousLinePosition = linePositions[linePositions.length - 1] ?? 0;
|
||||
|
||||
linePositions.push(previousLinePosition + trackSize * scale);
|
||||
}
|
||||
|
||||
linePositions[linePositions.length - 1] = availableSize;
|
||||
|
||||
return linePositions;
|
||||
}
|
||||
|
||||
private nearestGridLineIndex(offset: number, linePositions: number[]): number {
|
||||
const clampedOffset = this.clamp(offset, linePositions[0] ?? 0, linePositions.at(-1) ?? 0);
|
||||
|
||||
let nearestIndex = 0;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (let index = 0; index < linePositions.length; index++) {
|
||||
const distance = Math.abs(linePositions[index] - clampedOffset);
|
||||
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
return nearestIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,75 +11,149 @@ import {
|
||||
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 { css } from '@codemirror/lang-css';
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||
import { indentUnit, syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
Diagnostic,
|
||||
lintGutter,
|
||||
linter
|
||||
} from '@codemirror/lint';
|
||||
import {
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
Extension
|
||||
} 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'
|
||||
import { formatPastedJsonText } from './theme-json-format.logic';
|
||||
|
||||
type ThemeCodeEditorLanguage = 'json' | 'css';
|
||||
|
||||
const ERROR_SQUIGGLE_BACKGROUND =
|
||||
'linear-gradient(45deg, transparent 65%, #fb7185 65%, #fb7185 80%, transparent 80%), '
|
||||
+ 'linear-gradient(135deg, transparent 65%, #fb7185 65%, #fb7185 80%, transparent 80%)';
|
||||
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'
|
||||
},
|
||||
'.cm-diagnosticRange-error': {
|
||||
backgroundImage: ERROR_SQUIGGLE_BACKGROUND,
|
||||
backgroundPosition: '0 100%',
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundSize: '8px 3px',
|
||||
paddingBottom: '2px'
|
||||
},
|
||||
'.cm-lintRange-error': {
|
||||
backgroundImage: ERROR_SQUIGGLE_BACKGROUND,
|
||||
backgroundPosition: '0 100%',
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundSize: '8px 3px',
|
||||
paddingBottom: '2px'
|
||||
},
|
||||
'.cm-lintPoint-error:after': {
|
||||
borderBottomColor: '#fb7185'
|
||||
},
|
||||
'.cm-diagnostic': {
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
},
|
||||
'.cm-diagnostic-error': {
|
||||
borderLeftColor: '#fb7185'
|
||||
}
|
||||
},
|
||||
'&.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 }
|
||||
);
|
||||
|
||||
function getLanguageExtensions(language: ThemeCodeEditorLanguage): Extension[] {
|
||||
if (language === 'css') {
|
||||
return [css(), linter(cssSyntaxLinter, { delay: 250 })];
|
||||
}
|
||||
}, { dark: true });
|
||||
|
||||
return [json(), linter(jsonParseLinter(), { delay: 250 })];
|
||||
}
|
||||
|
||||
function cssSyntaxLinter(view: EditorView): Diagnostic[] {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const documentLength = view.state.doc.length;
|
||||
|
||||
syntaxTree(view.state).iterate({
|
||||
enter: (node) => {
|
||||
if (!node.type.isError) {
|
||||
return;
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
from: node.from,
|
||||
to: Math.max(node.to, Math.min(node.from + 1, documentLength)),
|
||||
severity: 'error',
|
||||
source: 'CSS',
|
||||
message: 'CSS syntax error.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-json-code-editor',
|
||||
@@ -91,9 +165,10 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef');
|
||||
readonly value = input.required<string>();
|
||||
readonly fullscreen = input(false);
|
||||
readonly language = input<ThemeCodeEditorLanguage>('json');
|
||||
readonly valueChange = output<string>();
|
||||
|
||||
readonly editorMinHeight = computed(() => this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem');
|
||||
readonly editorMinHeight = computed(() => (this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem'));
|
||||
|
||||
private readonly zone = inject(NgZone);
|
||||
|
||||
@@ -173,6 +248,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
|
||||
private createEditor(host: HTMLDivElement): void {
|
||||
this.zone.runOutsideAngular(() => {
|
||||
const language = this.language();
|
||||
|
||||
this.editorView = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: this.value(),
|
||||
@@ -180,7 +257,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
indentUnit.of(' '),
|
||||
json(),
|
||||
...getLanguageExtensions(language),
|
||||
lintGutter(),
|
||||
oneDark,
|
||||
THEME_JSON_EDITOR_THEME,
|
||||
EditorState.tabSize.of(2),
|
||||
@@ -188,7 +266,25 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
spellcheck: 'false',
|
||||
autocapitalize: 'off',
|
||||
autocorrect: 'off',
|
||||
'aria-label': 'Theme JSON editor'
|
||||
'aria-label': language === 'css' ? 'Theme CSS editor' : 'Theme JSON editor'
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
paste: (event, view) => {
|
||||
if (language !== 'json') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pastedText = event.clipboardData?.getData('application/json') || event.clipboardData?.getData('text/plain') || '';
|
||||
const formattedText = formatPastedJsonText(pastedText);
|
||||
|
||||
if (!formattedText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.replaceSelection(formattedText));
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged || this.isApplyingExternalValue) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { formatPastedJsonText } from './theme-json-format.logic';
|
||||
|
||||
describe('theme JSON paste formatting', () => {
|
||||
it('formats pasted JSON with two-space indentation', () => {
|
||||
expect(formatPastedJsonText('{"meta":{"name":"Paste","version":"1"},"tokens":{"colors":{"background":"1 2% 3%"}}}')).toBe([
|
||||
'{',
|
||||
' "meta": {',
|
||||
' "name": "Paste",',
|
||||
' "version": "1"',
|
||||
' },',
|
||||
' "tokens": {',
|
||||
' "colors": {',
|
||||
' "background": "1 2% 3%"',
|
||||
' }',
|
||||
' }',
|
||||
'}'
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('leaves non-JSON paste content to the editor default', () => {
|
||||
expect(formatPastedJsonText('backgroundColor: red')).toBeNull();
|
||||
expect(formatPastedJsonText('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export function formatPastedJsonText(text: string): string | null {
|
||||
const trimmedText = text.trim();
|
||||
|
||||
if (!trimmedText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmedText) as unknown, null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,13 @@
|
||||
>
|
||||
Format JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="jumpToCss()"
|
||||
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Open CSS
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="copyLlmThemeGuide()"
|
||||
@@ -33,13 +40,35 @@
|
||||
>
|
||||
Copy LLM Guide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="themeFileInput.click()"
|
||||
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Import File
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="exportThemeFile()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Export File
|
||||
</button>
|
||||
<input
|
||||
#themeFileInput
|
||||
type="file"
|
||||
accept=".json,.css,application/json,text/css"
|
||||
class="hidden"
|
||||
(change)="importThemeFile($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
(click)="applyDraft()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="inline-flex items-center rounded-md 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
|
||||
{{ activeEditorTab() === 'cssOnly' ? 'Apply CSS Theme' : 'Apply Draft' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -59,24 +88,8 @@
|
||||
|
||||
<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
|
||||
for="theme-studio-workspace-select"
|
||||
class="theme-settings__workspace-selector-label"
|
||||
>
|
||||
Workspace
|
||||
</label>
|
||||
<select
|
||||
id="theme-studio-workspace-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>
|
||||
<span class="theme-settings__hero-label">Workspace</span>
|
||||
<strong class="theme-settings__hero-value">{{ activeWorkspaceInfo().label }}</strong>
|
||||
</div>
|
||||
<div class="theme-settings__hero-stat">
|
||||
<span class="theme-settings__hero-label">Regions</span>
|
||||
@@ -110,6 +123,43 @@
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<nav
|
||||
class="theme-settings__workspace-tabs mt-4"
|
||||
aria-label="Theme Studio workspace"
|
||||
>
|
||||
@for (workspace of workspaceTabs; track workspace.key) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="setWorkspace(workspace.key)"
|
||||
class="theme-settings__workspace-tab"
|
||||
[class.theme-settings__workspace-tab--active]="activeWorkspace() === workspace.key"
|
||||
[attr.aria-current]="activeWorkspace() === workspace.key ? 'page' : null"
|
||||
>
|
||||
<span class="theme-settings__workspace-tab-label">{{ workspace.label }}</span>
|
||||
<span class="theme-settings__workspace-tab-description">{{ workspace.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--mobile mt-4">
|
||||
<label
|
||||
for="theme-studio-workspace-select"
|
||||
class="theme-settings__workspace-selector-label"
|
||||
>
|
||||
Workspace
|
||||
</label>
|
||||
<select
|
||||
id="theme-studio-workspace-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>
|
||||
</section>
|
||||
|
||||
<div class="theme-settings__workspace min-h-0 flex-1">
|
||||
@@ -222,7 +272,7 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="theme-studio-card p-3.5">
|
||||
<section class="theme-studio-card theme-settings__explorer-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">
|
||||
@@ -240,7 +290,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="theme-settings__entry-list mt-4">
|
||||
<div class="theme-settings__entry-list theme-settings__explorer-list mt-4">
|
||||
@for (entry of filteredEntries(); track entry.key) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -318,23 +368,70 @@
|
||||
@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="min-w-0">
|
||||
<p class="text-sm font-semibold text-foreground">
|
||||
{{ activeEditorTab() === 'cssOnly' ? 'CSS-Only Theme' : 'Theme JSON' }}
|
||||
</p>
|
||||
@if (activeEditorTab() === 'cssOnly') {
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">CSS here is applied over the built-in default JSON theme.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftLineCount() }} lines</span>
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftCharacterCount() }} chars</span>
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1"
|
||||
>{{ activeEditorTab() === 'cssOnly' ? cssOnlyText().split('\n').length : draftLineCount() }} lines</span
|
||||
>
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1"
|
||||
>{{ activeEditorTab() === 'cssOnly' ? cssOnlyText().length : draftCharacterCount() }} chars</span
|
||||
>
|
||||
@if (activeEditorTab() === 'json') {
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
|
||||
}
|
||||
<span class="rounded-md bg-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="setEditorTab('json')"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
[class.border-primary/40]="activeEditorTab() === 'json'"
|
||||
[class.bg-primary/10]="activeEditorTab() === 'json'"
|
||||
[attr.aria-current]="activeEditorTab() === 'json' ? 'page' : null"
|
||||
>
|
||||
JSON Theme
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="setEditorTab('cssOnly')"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
[class.border-primary/40]="activeEditorTab() === 'cssOnly'"
|
||||
[class.bg-primary/10]="activeEditorTab() === 'cssOnly'"
|
||||
[attr.aria-current]="activeEditorTab() === 'cssOnly' ? 'page' : null"
|
||||
>
|
||||
CSS Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="theme-settings__editor-panel pt-3">
|
||||
<app-theme-json-code-editor
|
||||
#jsonEditorRef
|
||||
[value]="draftText()"
|
||||
[fullscreen]="isFullscreen()"
|
||||
(valueChange)="onDraftEditorValueChange($event)"
|
||||
/>
|
||||
@if (activeEditorTab() === 'json') {
|
||||
<app-theme-json-code-editor
|
||||
#jsonEditorRef
|
||||
[value]="draftText()"
|
||||
[fullscreen]="isFullscreen()"
|
||||
language="json"
|
||||
(valueChange)="onDraftEditorValueChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-theme-json-code-editor
|
||||
#jsonEditorRef
|
||||
[value]="cssOnlyText()"
|
||||
[fullscreen]="isFullscreen()"
|
||||
language="css"
|
||||
(valueChange)="onCssOnlyEditorValueChange($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -370,7 +467,7 @@
|
||||
|
||||
<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>
|
||||
<p class="text-sm font-semibold text-foreground">Editable Attributes</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -468,7 +565,7 @@
|
||||
|
||||
<div class="mt-5">
|
||||
<app-theme-grid-editor
|
||||
[container]="selectedContainer() === 'appShell' ? layoutContainers[0] : layoutContainers[1]"
|
||||
[container]="selectedLayoutContainer()"
|
||||
[items]="selectedContainerItems()"
|
||||
[selectedKey]="selectedElementKey()"
|
||||
[disabled]="!draftIsValid()"
|
||||
|
||||
@@ -18,6 +18,54 @@
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector--mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab {
|
||||
min-height: 5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--secondary) / 0.22);
|
||||
padding: 0.8rem 0.9rem;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab:hover {
|
||||
background: hsl(var(--secondary) / 0.42);
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab--active {
|
||||
border-color: hsl(var(--primary) / 0.45);
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab-description {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector-label {
|
||||
font-size: 0.69rem;
|
||||
font-weight: 700;
|
||||
@@ -52,6 +100,29 @@
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
grid-template-columns: minmax(17rem, 22rem) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-settings__sidebar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-settings__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-settings__editor-card {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
@@ -92,6 +163,22 @@
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.theme-settings__explorer-card {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-settings__explorer-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.theme-json-editor-panel__header {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -114,3 +201,22 @@
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.theme-settings__workspace-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector--mobile {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.theme-settings__workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-settings__explorer-card {
|
||||
max-height: min(28rem, calc(100vh - 10rem));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
||||
import { getElectronApi } from '../../../../../core/platform/electron/get-electron-api';
|
||||
import {
|
||||
ThemeContainerKey,
|
||||
ThemeElementStyleProperty,
|
||||
@@ -29,7 +30,8 @@ import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.const
|
||||
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
|
||||
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
|
||||
|
||||
type JumpSection = 'elements' | 'layout' | 'animations';
|
||||
type JumpSection = 'elements' | 'layout' | 'animations' | 'css';
|
||||
type ThemeEditorTab = 'json' | 'cssOnly';
|
||||
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||
|
||||
@Component({
|
||||
@@ -59,6 +61,7 @@ export class ThemeSettingsComponent {
|
||||
readonly statusMessage = this.theme.statusMessage;
|
||||
readonly isDraftDirty = this.theme.isDraftDirty;
|
||||
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
||||
readonly activeTheme = this.theme.activeTheme;
|
||||
readonly draftTheme = this.theme.draftTheme;
|
||||
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
||||
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||
@@ -83,6 +86,8 @@ export class ThemeSettingsComponent {
|
||||
];
|
||||
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
||||
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
||||
readonly activeEditorTab = signal<ThemeEditorTab>('json');
|
||||
readonly cssOnlyText = signal('');
|
||||
readonly explorerQuery = signal('');
|
||||
|
||||
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
||||
@@ -103,9 +108,15 @@ export class ThemeSettingsComponent {
|
||||
].filter((value): value is string => value !== null);
|
||||
});
|
||||
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
||||
readonly selectedLayoutContainer = computed(() => {
|
||||
return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0];
|
||||
});
|
||||
readonly selectedElementGrid = computed(() => {
|
||||
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
|
||||
});
|
||||
readonly activeWorkspaceInfo = computed(() => {
|
||||
return this.workspaceTabs.find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs[0];
|
||||
});
|
||||
readonly visiblePropertyHints = computed(() => {
|
||||
const selected = this.selectedElement();
|
||||
|
||||
@@ -156,6 +167,8 @@ export class ThemeSettingsComponent {
|
||||
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
|
||||
if (this.savedThemesAvailable()) {
|
||||
void this.themeLibrary.refresh();
|
||||
}
|
||||
@@ -167,8 +180,16 @@ export class ThemeSettingsComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('inspector');
|
||||
this.selectThemeEntry(pickedKey, 'elements');
|
||||
this.openPickedElementInJson(pickedKey);
|
||||
this.picker.clearSelection();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.activeEditorTab() !== 'json') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cssOnlyText.set(this.draftTheme().css);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -186,10 +207,35 @@ export class ThemeSettingsComponent {
|
||||
this.theme.updateDraftText(value);
|
||||
}
|
||||
|
||||
onCssOnlyEditorValueChange(value: string): void {
|
||||
this.cssOnlyText.set(value);
|
||||
}
|
||||
|
||||
applyDraft(): void {
|
||||
if (this.activeEditorTab() === 'cssOnly') {
|
||||
this.applyCssOnlyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
this.theme.applyDraft();
|
||||
}
|
||||
|
||||
applyCssOnlyTheme(): void {
|
||||
this.theme.applyCssOnlyTheme(this.cssOnlyText());
|
||||
}
|
||||
|
||||
setEditorTab(tab: ThemeEditorTab): void {
|
||||
this.activeEditorTab.set(tab);
|
||||
|
||||
if (tab === 'json') {
|
||||
this.focusEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
setWorkspace(workspace: ThemeStudioWorkspace): void {
|
||||
this.activeWorkspace.set(workspace);
|
||||
|
||||
@@ -202,6 +248,7 @@ export class ThemeSettingsComponent {
|
||||
}
|
||||
|
||||
if (workspace === 'editor') {
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusEditor();
|
||||
}
|
||||
}
|
||||
@@ -223,12 +270,36 @@ export class ThemeSettingsComponent {
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
async exportThemeFile(): Promise<void> {
|
||||
const exportText = this.getExportThemeText();
|
||||
|
||||
if (!exportText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = `${this.sanitizeThemeFileName(this.draftTheme().meta.name)}.json`;
|
||||
const saved = await this.saveTextAsFile(fileName, exportText);
|
||||
|
||||
this.theme.announceStatus(saved ? `${fileName} exported.` : 'Theme export cancelled.');
|
||||
}
|
||||
|
||||
importThemeFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
|
||||
input.value = '';
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.loadThemeFile(file);
|
||||
}
|
||||
|
||||
async copyLlmThemeGuide(): Promise<void> {
|
||||
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
|
||||
|
||||
this.setLlmGuideCopyMessage(copied
|
||||
? 'LLM guide copied.'
|
||||
: 'Manual copy opened.');
|
||||
this.setLlmGuideCopyMessage(copied ? 'LLM guide copied.' : 'Manual copy opened.');
|
||||
}
|
||||
|
||||
startPicker(): void {
|
||||
@@ -285,9 +356,10 @@ export class ThemeSettingsComponent {
|
||||
restoreDefaultTheme(): void {
|
||||
this.theme.resetToDefault('button');
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.selectedContainer.set('roomLayout');
|
||||
this.selectedElementKey.set('chatRoomMainPanel');
|
||||
this.focusJsonAnchor('elements', 'chatRoomMainPanel');
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
selectThemeEntry(key: string, section: JumpSection = 'elements'): void {
|
||||
@@ -299,7 +371,7 @@ export class ThemeSettingsComponent {
|
||||
|
||||
if (section === 'layout') {
|
||||
this.activeWorkspace.set('layout');
|
||||
} else if (section === 'animations') {
|
||||
} else if (section === 'animations' || section === 'css') {
|
||||
this.activeWorkspace.set('editor');
|
||||
} else {
|
||||
this.activeWorkspace.set('inspector');
|
||||
@@ -331,6 +403,9 @@ export class ThemeSettingsComponent {
|
||||
const value = getSuggestedFieldDefault(property, this.animationKeys());
|
||||
|
||||
this.theme.setElementStyle(this.selectedElementKey(), property, value);
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonProperty('elements', this.selectedElementKey(), property);
|
||||
}
|
||||
|
||||
addStarterAnimation(): void {
|
||||
@@ -355,37 +430,200 @@ export class ThemeSettingsComponent {
|
||||
|
||||
jumpToLayout(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonAnchor('layout', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToStyles(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonAnchor('elements', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToAnimation(animationKey: string): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonAnchor('animations', animationKey);
|
||||
}
|
||||
|
||||
jumpToCss(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('cssOnly');
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
isMounted(entry: ThemeRegistryEntry): boolean {
|
||||
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private focusEditor(): void {
|
||||
this.withEditorReady((editor) => {
|
||||
editor.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private withEditorReady(action: (editor: ThemeJsonCodeEditorComponent) => void, attempts = 8): void {
|
||||
queueMicrotask(() => {
|
||||
this.editorRef()?.focus();
|
||||
const editor = this.editorRef();
|
||||
|
||||
if (editor) {
|
||||
action(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempts <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.withEditorReady(action, attempts - 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private syncCssOnlyTextFromTheme(): void {
|
||||
this.cssOnlyText.set(this.activeTheme().css || this.draftTheme().css);
|
||||
}
|
||||
|
||||
private getExportThemeText(): string | null {
|
||||
if (this.activeEditorTab() === 'cssOnly') {
|
||||
return this.theme.buildDraftTextWithCss(this.cssOnlyText());
|
||||
}
|
||||
|
||||
if (!this.draftIsValid()) {
|
||||
this.theme.announceStatus('Fix JSON errors before exporting the theme.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.draftText();
|
||||
}
|
||||
|
||||
private async loadThemeFile(file: File): Promise<void> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.css')) {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('cssOnly');
|
||||
this.cssOnlyText.set(text);
|
||||
this.theme.announceStatus(`${file.name} loaded into the CSS editor.`);
|
||||
this.focusEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = this.theme.loadThemeText(text, 'draft', `${file.name} imported into the draft editor.`, 'imported theme file');
|
||||
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
this.focusEditor();
|
||||
} catch {
|
||||
this.theme.announceStatus(`Unable to import ${file.name}.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveTextAsFile(fileName: string, text: string): Promise<boolean> {
|
||||
const electronApi = getElectronApi();
|
||||
|
||||
if (electronApi) {
|
||||
const result = await electronApi.saveFileAs(fileName, this.encodeBase64(text));
|
||||
|
||||
return result.saved;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private encodeBase64(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
|
||||
let binary = '';
|
||||
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
private sanitizeThemeFileName(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized.length > 0 ? normalized : 'theme';
|
||||
}
|
||||
|
||||
private openPickedElementInJson(key: string): void {
|
||||
const definition = this.registry.getDefinition(key);
|
||||
|
||||
if (!definition) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedElementKey.set(key);
|
||||
|
||||
if (definition.container) {
|
||||
this.selectedContainer.set(definition.container);
|
||||
}
|
||||
|
||||
if (this.draftIsValid()) {
|
||||
this.theme.ensureElementEntry(key);
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonElementEntry(key);
|
||||
}
|
||||
|
||||
private focusJsonElementEntry(key: string): void {
|
||||
this.withEditorReady((editor) => {
|
||||
let text = this.draftText();
|
||||
let anchorIndex = this.findAnchorIndex(text, 'elements', key);
|
||||
|
||||
if (anchorIndex === -1 && this.draftIsValid()) {
|
||||
this.theme.ensureElementEntry(key);
|
||||
text = this.draftText();
|
||||
anchorIndex = this.findAnchorIndex(text, 'elements', key);
|
||||
}
|
||||
|
||||
if (anchorIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const colonIndex = text.indexOf(':', anchorIndex);
|
||||
const objectStart = colonIndex === -1 ? -1 : text.indexOf('{', colonIndex);
|
||||
|
||||
if (objectStart === -1) {
|
||||
editor.focusRange(anchorIndex, Math.min(anchorIndex + key.length + 2, text.length));
|
||||
return;
|
||||
}
|
||||
|
||||
editor.focusRange(objectStart + 1, objectStart + 1);
|
||||
});
|
||||
}
|
||||
|
||||
private focusJsonAnchor(section: JumpSection, key: string): void {
|
||||
queueMicrotask(() => {
|
||||
const editor = this.editorRef();
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.withEditorReady((editor) => {
|
||||
let text = this.draftText();
|
||||
let anchorIndex = this.findAnchorIndex(text, section, key);
|
||||
|
||||
@@ -413,6 +651,94 @@ export class ThemeSettingsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private focusJsonProperty(section: JumpSection, key: string, property: string): void {
|
||||
this.withEditorReady((editor) => {
|
||||
const text = this.draftText();
|
||||
const keyIndex = this.findAnchorIndex(text, section, key);
|
||||
|
||||
if (keyIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const propertyIndex = text.indexOf(`"${property}"`, keyIndex);
|
||||
|
||||
if (propertyIndex === -1) {
|
||||
editor.focusRange(keyIndex, Math.min(keyIndex + key.length + 2, text.length));
|
||||
return;
|
||||
}
|
||||
|
||||
const valueRange = this.findJsonPropertyValueRange(text, propertyIndex);
|
||||
|
||||
editor.focusRange(valueRange.start, valueRange.end);
|
||||
});
|
||||
}
|
||||
|
||||
private findJsonPropertyValueRange(text: string, propertyIndex: number): { start: number; end: number } {
|
||||
const colonIndex = text.indexOf(':', propertyIndex);
|
||||
|
||||
if (colonIndex === -1) {
|
||||
return {
|
||||
start: propertyIndex,
|
||||
end: propertyIndex
|
||||
};
|
||||
}
|
||||
|
||||
let valueStart = colonIndex + 1;
|
||||
|
||||
while (valueStart < text.length && /\s/.test(text[valueStart])) {
|
||||
valueStart += 1;
|
||||
}
|
||||
|
||||
if (text[valueStart] === '"') {
|
||||
const stringContentStart = valueStart + 1;
|
||||
|
||||
let stringContentEnd = stringContentStart;
|
||||
|
||||
while (stringContentEnd < text.length) {
|
||||
if (text[stringContentEnd] === '"' && text[stringContentEnd - 1] !== '\\') {
|
||||
break;
|
||||
}
|
||||
|
||||
stringContentEnd += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
start: stringContentStart,
|
||||
end: stringContentEnd
|
||||
};
|
||||
}
|
||||
|
||||
let valueEnd = valueStart;
|
||||
|
||||
while (valueEnd < text.length && ![
|
||||
',',
|
||||
'\n',
|
||||
'}'
|
||||
].includes(text[valueEnd])) {
|
||||
valueEnd += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
start: valueStart,
|
||||
end: Math.max(valueStart, valueEnd)
|
||||
};
|
||||
}
|
||||
|
||||
private focusJsonSection(section: JumpSection): void {
|
||||
this.withEditorReady((editor) => {
|
||||
const text = this.draftText();
|
||||
const anchorIndex = text.indexOf(`"${section}"`);
|
||||
|
||||
if (anchorIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
editor.focusRange(anchorIndex, Math.min(anchorIndex + section.length + 2, text.length));
|
||||
});
|
||||
}
|
||||
|
||||
private async copyTextToClipboard(value: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ExternalLinkService } from '../../../core/platform';
|
||||
import { ElementPickerService } from '../application/services/element-picker.service';
|
||||
import { ThemeRegistryService } from '../application/services/theme-registry.service';
|
||||
import { ThemeService } from '../application/services/theme.service';
|
||||
import { applyThemeStyleDeclaration } from './theme-style-application.logic';
|
||||
|
||||
function looksLikeImageReference(value: string): boolean {
|
||||
return value.startsWith('url(')
|
||||
@@ -96,8 +97,7 @@ export class ThemeNodeDirective implements OnDestroy {
|
||||
this.clearAppliedStyles();
|
||||
|
||||
for (const [styleKey, styleValue] of Object.entries(styles)) {
|
||||
this.host.nativeElement.style.setProperty(styleKey, styleValue);
|
||||
this.appliedStyleKeys.add(styleKey);
|
||||
this.appliedStyleKeys.add(applyThemeStyleDeclaration(this.host.nativeElement, styleKey, styleValue));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
applyThemeStyleDeclaration,
|
||||
toCssStylePropertyName
|
||||
} from './theme-style-application.logic';
|
||||
|
||||
describe('theme style application', () => {
|
||||
it('applies camelCase theme properties as real CSS declarations', () => {
|
||||
const host = createHost();
|
||||
|
||||
expect(applyThemeStyleDeclaration(host, 'backgroundImage', 'url("/theme.png")')).toBe('background-image');
|
||||
expect(applyThemeStyleDeclaration(host, 'borderRadius', '12px')).toBe('border-radius');
|
||||
expect(applyThemeStyleDeclaration(host, 'boxShadow', '0 4px 20px rgba(0, 0, 0, 0.25)')).toBe('box-shadow');
|
||||
|
||||
expect(host.style.backgroundImage).toContain('/theme.png');
|
||||
expect(host.style.borderRadius).toBe('12px');
|
||||
expect(host.style.boxShadow).toBe('0 4px 20px rgba(0, 0, 0, 0.25)');
|
||||
});
|
||||
|
||||
it('keeps CSS custom properties intact', () => {
|
||||
const host = createHost();
|
||||
|
||||
expect(toCssStylePropertyName('--theme-effect-glass-blur')).toBe('--theme-effect-glass-blur');
|
||||
applyThemeStyleDeclaration(host, '--theme-effect-glass-blur', 'blur(18px)');
|
||||
|
||||
expect(host.style.getPropertyValue('--theme-effect-glass-blur')).toBe('blur(18px)');
|
||||
});
|
||||
});
|
||||
|
||||
function createHost(): HTMLElement {
|
||||
const values = new Map<string, string>();
|
||||
const style = {
|
||||
backgroundImage: '',
|
||||
borderRadius: '',
|
||||
boxShadow: '',
|
||||
setProperty(propertyName: string, value: string) {
|
||||
values.set(propertyName, value);
|
||||
|
||||
if (propertyName === 'background-image') {
|
||||
this.backgroundImage = value;
|
||||
}
|
||||
|
||||
if (propertyName === 'border-radius') {
|
||||
this.borderRadius = value;
|
||||
}
|
||||
|
||||
if (propertyName === 'box-shadow') {
|
||||
this.boxShadow = value;
|
||||
}
|
||||
},
|
||||
getPropertyValue(propertyName: string) {
|
||||
return values.get(propertyName) ?? '';
|
||||
}
|
||||
};
|
||||
|
||||
return { style } as unknown as HTMLElement;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function toCssStylePropertyName(propertyName: string): string {
|
||||
if (propertyName.startsWith('--')) {
|
||||
return propertyName;
|
||||
}
|
||||
|
||||
return propertyName.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
}
|
||||
|
||||
export function applyThemeStyleDeclaration(host: HTMLElement, propertyName: string, value: string): string {
|
||||
const cssPropertyName = toCssStylePropertyName(propertyName);
|
||||
|
||||
host.style.setProperty(cssPropertyName, value);
|
||||
return cssPropertyName;
|
||||
}
|
||||
Reference in New Issue
Block a user