chore: add svelte component layer with monaco editor
This commit is contained in:
1 parent
8071085e94
commit
54f7f81160
55 files changed
+20563
-2043
No files matched your search
@@ -43,7 +43,11 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!visible || !src) return;
|
||||
if (!visible) return;
|
||||
if (!src) {
|
||||
renderedFamily = family;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const previewFamily = `EZPPSourcePreview_${previewCount++}`;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,764 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
analyzeThemeSvelteSource,
|
||||
type ThemeSvelteAnalysis,
|
||||
type ThemeSvelteDiagnostic,
|
||||
} from '$lib/themeSvelte';
|
||||
import {
|
||||
THEME_SVELTE_COMPONENT_PLACEHOLDER,
|
||||
THEME_SVELTE_COMPONENT_SKELETON,
|
||||
} from '$lib/themeSvelteSnippets';
|
||||
import { Component, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type MonacoApi = typeof import('monaco-editor');
|
||||
|
||||
let {
|
||||
value = '',
|
||||
onchange,
|
||||
variables = [],
|
||||
height = 320,
|
||||
}: {
|
||||
value?: string;
|
||||
onchange?: (value: string) => void;
|
||||
variables?: string[];
|
||||
height?: number;
|
||||
} = $props();
|
||||
|
||||
const LANGUAGE_ID = 'ezpp-theme-svelte';
|
||||
const THEME_ID = 'ezpp-theme-svelte-dark';
|
||||
const MARKER_OWNER = 'ezpp-theme-svelte';
|
||||
// Keep these split so the Svelte parser cannot mistake a string literal for
|
||||
// the end of this component's own script block.
|
||||
const SCRIPT_OPEN = '<scr' + 'ipt>';
|
||||
const SCRIPT_CLOSE = '</scr' + 'ipt>';
|
||||
const STYLE_OPEN = '<sty' + 'le>';
|
||||
const STYLE_CLOSE = '</sty' + 'le>';
|
||||
|
||||
const propDocs: { label: string; documentation: string; insert: string }[] = [
|
||||
{
|
||||
label: '$props',
|
||||
insert:
|
||||
'let { variables = {}, layer = {}, theme = {}, asset = (path) => path, screen = "home" } = $props();',
|
||||
documentation:
|
||||
'Receive live theme props: `variables`, `layer`, `theme`, `screen` and the `asset(path)` helper.',
|
||||
},
|
||||
{
|
||||
label: 'variables',
|
||||
insert: 'variables.${1:name}',
|
||||
documentation: 'Live rule variables. They update when a Logic rule changes a variable.',
|
||||
},
|
||||
{
|
||||
label: 'layer',
|
||||
insert: 'layer.${1:width}',
|
||||
documentation:
|
||||
'The layer bounds: `layer.id`, `layer.name`, `layer.width` and `layer.height`.',
|
||||
},
|
||||
{
|
||||
label: 'theme',
|
||||
insert: 'theme.${1:name}',
|
||||
documentation: 'Theme metadata: `theme.name`, `theme.author` and `theme.accentColor`.',
|
||||
},
|
||||
{
|
||||
label: 'asset',
|
||||
insert: 'asset("${1:image.png}")',
|
||||
documentation:
|
||||
'Resolve a file inside the theme `assets/` folder. Works in the launcher and editor preview.',
|
||||
},
|
||||
{
|
||||
label: 'screen',
|
||||
insert: 'screen',
|
||||
documentation: 'The current launcher screen. Custom layers only render on Home today.',
|
||||
},
|
||||
{
|
||||
label: '$state',
|
||||
insert: 'let ${1:value} = $state(${2:0});',
|
||||
documentation: 'Create reactive component state.',
|
||||
},
|
||||
{
|
||||
label: '$derived',
|
||||
insert: 'const ${1:value} = $derived(${2:expression});',
|
||||
documentation: 'Create a value derived from other reactive state.',
|
||||
},
|
||||
{
|
||||
label: '$effect',
|
||||
insert: '$effect(() => {\n\t$0\n});',
|
||||
documentation: 'Run a side effect when reactive values change.',
|
||||
},
|
||||
{
|
||||
label: 'onMount',
|
||||
insert: 'import { onMount } from "svelte";\n\nonMount(() => {\n\t$0\n});',
|
||||
documentation: 'Run code after the component mounts. Import it from `svelte`.',
|
||||
},
|
||||
{
|
||||
label: 'onDestroy',
|
||||
insert: 'import { onDestroy } from "svelte";\n\nonDestroy(() => {\n\t$0\n});',
|
||||
documentation: 'Clean up timers, listeners and observers before the layer unmounts.',
|
||||
},
|
||||
{
|
||||
label: 'tick',
|
||||
insert: 'import { tick } from "svelte";\n\nawait tick();',
|
||||
documentation: 'Wait for pending DOM updates.',
|
||||
},
|
||||
{
|
||||
label: 'import transition',
|
||||
insert: 'import { ${1:fade} } from "svelte/transition";',
|
||||
documentation:
|
||||
'Import transitions such as `fade`, `fly`, `slide`, `scale`, `blur` and `draw` from `svelte/transition`.',
|
||||
},
|
||||
{
|
||||
label: 'import store',
|
||||
insert: 'import { ${1:writable} } from "svelte/store";',
|
||||
documentation: 'Svelte stores are available from `svelte/store`.',
|
||||
},
|
||||
{
|
||||
label: 'SvelteMap',
|
||||
insert: 'import { SvelteMap } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteMap();',
|
||||
documentation: 'Reactive Map from `svelte/reactivity`.',
|
||||
},
|
||||
{
|
||||
label: 'SvelteSet',
|
||||
insert: 'import { SvelteSet } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteSet();',
|
||||
documentation: 'Reactive Set from `svelte/reactivity`.',
|
||||
},
|
||||
{
|
||||
label: '{#if}',
|
||||
insert: '{#if ${1:condition}}\n\t$0\n{/if}',
|
||||
documentation: 'Conditionally render markup.',
|
||||
},
|
||||
{
|
||||
label: '{#each}',
|
||||
insert: '{#each ${1:items} as ${2:item}}\n\t$0\n{/each}',
|
||||
documentation: 'Render a list. Add `(${3:item.id})` for a keyed each block.',
|
||||
},
|
||||
{
|
||||
label: '{#snippet}',
|
||||
insert: '{#snippet ${1:name}(${2:argument})}\n\t$0\n{/snippet}',
|
||||
documentation: 'Declare a reusable snippet and render it with `{@render name(argument)}`.',
|
||||
},
|
||||
{
|
||||
label: '{@render}',
|
||||
insert: '{@render ${1:name}(${2:argument})}',
|
||||
documentation: 'Render a snippet declared in this component.',
|
||||
},
|
||||
{
|
||||
label: SCRIPT_OPEN,
|
||||
insert: `${SCRIPT_OPEN}\n\t$0\n${SCRIPT_CLOSE}`,
|
||||
documentation: 'Plain JavaScript only. TypeScript is not preprocessed in theme layers.',
|
||||
},
|
||||
{
|
||||
label: STYLE_OPEN,
|
||||
insert: `${STYLE_OPEN}\n\t$0\n${STYLE_CLOSE}`,
|
||||
documentation: 'Scoped CSS for this component.',
|
||||
},
|
||||
];
|
||||
|
||||
let host = $state<HTMLDivElement>();
|
||||
let monaco: MonacoApi | undefined;
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
|
||||
let model: Monaco.editor.ITextModel | undefined;
|
||||
let ready = $state(false);
|
||||
let loadError = $state('');
|
||||
let expanded = $state(false);
|
||||
let lineCount = $state(1);
|
||||
let cursorLine = $state(1);
|
||||
let cursorColumn = $state(1);
|
||||
let errorCount = $state(0);
|
||||
let warningCount = $state(0);
|
||||
let checking = $state(false);
|
||||
let diagnostics = $state<ThemeSvelteDiagnostic[]>([]);
|
||||
let currentValue = '';
|
||||
let validateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let validationToken = 0;
|
||||
|
||||
const ensureLanguage = (api: MonacoApi) => {
|
||||
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
|
||||
api.languages.register({ id: LANGUAGE_ID });
|
||||
}
|
||||
|
||||
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
|
||||
comments: { blockComment: ['<!--', '-->'] },
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')'],
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
indentationRules: {
|
||||
increaseIndentPattern: /<[^/!][^>]*>\s*$|\{[#:@][^}]*$/,
|
||||
decreaseIndentPattern: /^\s*<\/|\{\/|\{:else/,
|
||||
},
|
||||
});
|
||||
|
||||
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/<!--/, { token: 'comment', next: '@comment' }],
|
||||
[/<scr(?:ipt)\b[^>]*>/, { token: 'tag', next: '@script' }],
|
||||
[/<sty(?:le)\b[^>]*>/, { token: 'tag', next: '@style' }],
|
||||
[/<\/?[\w:-]+/, 'tag'],
|
||||
[/[a-zA-Z_][\w-]*(?=\s*=)/, 'attribute.name'],
|
||||
[/"[^"]*"|'[^']*'/, 'string'],
|
||||
[/\{[#:/@][^}]*\}/, 'keyword'],
|
||||
[/\{[^}]*\}/, 'expression'],
|
||||
[/[<>/=]/, 'delimiter'],
|
||||
[/[^<{]+/, 'text'],
|
||||
[/[<{]/, 'delimiter'],
|
||||
],
|
||||
comment: [
|
||||
[/[^-]+/, 'comment'],
|
||||
[/-->/, { token: 'comment', next: '@pop' }],
|
||||
[/./, 'comment'],
|
||||
],
|
||||
script: [
|
||||
[/<\/script\s*>/, { token: 'tag', next: '@pop' }],
|
||||
[/\/\/.*$/, 'comment'],
|
||||
[/\/\*/, 'comment', '@blockComment'],
|
||||
[
|
||||
/\b(?:import|from|let|const|var|if|else|for|while|return|function|async|await|new|typeof|export|class|extends|try|catch|finally|throw|break|continue|switch|case|default|of|in)\b/,
|
||||
'keyword',
|
||||
],
|
||||
[/\$(?:state|derived|effect|props|bindable|inspect)(?:\.\w+)?/, 'predefined'],
|
||||
[
|
||||
/\b(?:onMount|onDestroy|tick|mount|unmount|fade|fly|slide|scale|blur|draw|writable|readable|derived|SvelteMap|SvelteSet|Math|Number|String|Boolean|JSON)\b/,
|
||||
'predefined',
|
||||
],
|
||||
[/\b\d+(?:\.\d+)?\b/, 'number'],
|
||||
[/[A-Za-z_$][\w$]*/, 'identifier'],
|
||||
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|[=!<>+\-*/%?:]/, 'operator'],
|
||||
[/[{}()[\].,;]/, 'delimiter'],
|
||||
[/[^}\s]/, 'text'],
|
||||
],
|
||||
blockComment: [
|
||||
[/[^*/]+/, 'comment'],
|
||||
[/\*\//, { token: 'comment', next: '@pop' }],
|
||||
[/[*/]/, 'comment'],
|
||||
],
|
||||
style: [
|
||||
[/<\/style\s*>/, { token: 'tag', next: '@pop' }],
|
||||
[/\/\*/, 'comment', '@blockComment'],
|
||||
[/[.#][\w-]+/, 'attribute.name'],
|
||||
[
|
||||
/\b(?:var|calc|rgb|rgba|linear-gradient|radial-gradient|url|blur|clamp|min|max)\b/,
|
||||
'predefined',
|
||||
],
|
||||
[/[a-zA-Z-]+(?=\s*:)/, 'type'],
|
||||
[/#[0-9a-fA-F]{3,8}\b/, 'number.hex'],
|
||||
[/\b\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|deg|s|ms)?\b/, 'number'],
|
||||
[/"[^"]*"|'[^']*'/, 'string'],
|
||||
[/[{}();:,]/, 'delimiter'],
|
||||
[/[^}\s]/, 'text'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
api.editor.defineTheme(THEME_ID, {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
|
||||
{ token: 'predefined', foreground: '8be9fd' },
|
||||
{ token: 'tag', foreground: 'ff79c6' },
|
||||
{ token: 'attribute.name', foreground: '50fa7b' },
|
||||
{ token: 'identifier', foreground: 'f8f8f2' },
|
||||
{ token: 'expression', foreground: 'f8f8f2' },
|
||||
{ token: 'number', foreground: 'bd93f9' },
|
||||
{ token: 'number.hex', foreground: 'bd93f9' },
|
||||
{ token: 'string', foreground: 'f1fa8c' },
|
||||
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
|
||||
{ token: 'operator', foreground: 'ff79c6' },
|
||||
{ token: 'type', foreground: '8be9fd' },
|
||||
{ token: 'text', foreground: 'f8f8f2' },
|
||||
{ token: 'delimiter', foreground: 'a1a1aa' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#08080c',
|
||||
'editor.foreground': '#f4f4f5',
|
||||
'editorLineNumber.foreground': '#52525b',
|
||||
'editorLineNumber.activeForeground': '#e4e4e7',
|
||||
'editor.lineHighlightBackground': '#ffffff08',
|
||||
'editor.selectionBackground': '#ff006e33',
|
||||
'editor.inactiveSelectionBackground': '#ff006e22',
|
||||
'editorCursor.foreground': '#ff006e',
|
||||
'editorIndentGuide.background1': '#27272a',
|
||||
'editorIndentGuide.activeBackground1': '#52525b',
|
||||
'editorBracketHighlight.foreground1': '#ff79c6',
|
||||
'editorBracketHighlight.foreground2': '#8be9fd',
|
||||
'editorBracketHighlight.foreground3': '#f1fa8c',
|
||||
'editorGutter.background': '#08080c',
|
||||
'editorWidget.background': '#121218',
|
||||
'editorWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.background': '#121218',
|
||||
'editorSuggestWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.selectedBackground': '#ff006e26',
|
||||
'editorSuggestWidget.highlightForeground': '#ff79c6',
|
||||
'editorHoverWidget.background': '#121218',
|
||||
'editorHoverWidget.border': '#3f3f46',
|
||||
'editorError.foreground': '#f87171',
|
||||
'editorWarning.foreground': '#fbbf24',
|
||||
'editorOverviewRuler.errorForeground': '#f87171',
|
||||
'editorOverviewRuler.warningForeground': '#fbbf24',
|
||||
'scrollbarSlider.background': '#52525b55',
|
||||
'scrollbarSlider.hoverBackground': '#71717a88',
|
||||
'scrollbarSlider.activeBackground': '#a1a1aa99',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const completionRange = (
|
||||
api: MonacoApi,
|
||||
currentModel: Monaco.editor.ITextModel,
|
||||
position: Monaco.Position
|
||||
) => {
|
||||
const word = currentModel.getWordUntilPosition(position);
|
||||
return new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
);
|
||||
};
|
||||
|
||||
const completionPrefixes = ['variables.', 'layer.', 'theme.'];
|
||||
|
||||
// When the user has already typed `variables.`, Monaco replaces only the
|
||||
// word after the dot. Strip the prefix from the insertion so the completion
|
||||
// does not produce `variables.variables.score`.
|
||||
const stripTypedPrefix = (
|
||||
insertText: string,
|
||||
currentModel: Monaco.editor.ITextModel,
|
||||
range: Monaco.Range
|
||||
) => {
|
||||
const typed = currentModel
|
||||
.getLineContent(range.startLineNumber)
|
||||
.slice(0, range.startColumn - 1);
|
||||
for (const prefix of completionPrefixes) {
|
||||
if (typed.endsWith(prefix) && insertText.startsWith(prefix)) {
|
||||
return insertText.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return insertText;
|
||||
};
|
||||
|
||||
const registerIntellisense = (api: MonacoApi) => {
|
||||
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
|
||||
triggerCharacters: ['.', '{', '#', '"', "'", ':', '<'],
|
||||
provideCompletionItems(currentModel, position) {
|
||||
const range = completionRange(api, currentModel, position);
|
||||
const suggestions: Monaco.languages.CompletionItem[] = [];
|
||||
|
||||
for (const entry of propDocs) {
|
||||
suggestions.push({
|
||||
label: entry.label,
|
||||
kind:
|
||||
entry.label.charCodeAt(0) === 36 || entry.label.startsWith('{')
|
||||
? api.languages.CompletionItemKind.Keyword
|
||||
: entry.label.startsWith('import')
|
||||
? api.languages.CompletionItemKind.Module
|
||||
: api.languages.CompletionItemKind.Property,
|
||||
insertText: stripTypedPrefix(entry.insert, currentModel, range),
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: entry.documentation,
|
||||
sortText: `1_${entry.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
label: 'Svelte component skeleton',
|
||||
kind: api.languages.CompletionItemKind.Snippet,
|
||||
insertText: THEME_SVELTE_COMPONENT_SKELETON,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: 'A ready-to-edit layer with props, markup and scoped styles.',
|
||||
sortText: '0_skeleton',
|
||||
});
|
||||
|
||||
for (const variable of variables) {
|
||||
suggestions.push({
|
||||
label: `variables.${variable}`,
|
||||
kind: api.languages.CompletionItemKind.Variable,
|
||||
insertText: stripTypedPrefix(`variables.${variable}`, currentModel, range),
|
||||
range,
|
||||
documentation: `Live rule variable \`${variable}\`. Change it from a Logic rule.`,
|
||||
sortText: `0_${variable}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { suggestions, incomplete: false };
|
||||
},
|
||||
});
|
||||
|
||||
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
|
||||
provideHover(currentModel, position) {
|
||||
const word = currentModel.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
const entry = propDocs.find(
|
||||
(candidate) => candidate.label === word.word || candidate.label === `${word.word}`
|
||||
);
|
||||
if (!entry) return null;
|
||||
return {
|
||||
range: new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
),
|
||||
contents: [{ value: `**${entry.label}**\n\n${entry.documentation}` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
completion.dispose();
|
||||
hover.dispose();
|
||||
};
|
||||
};
|
||||
|
||||
const syncDiagnostics = (analysis: ThemeSvelteAnalysis) => {
|
||||
if (!monaco || !model) return;
|
||||
const all: ThemeSvelteDiagnostic[] = analysis.ok
|
||||
? analysis.warnings
|
||||
: [...analysis.errors, ...analysis.warnings];
|
||||
const markers: Monaco.editor.IMarkerData[] = all.map((diagnostic) => {
|
||||
const lineNumber = Math.min(Math.max(1, diagnostic.line), model!.getLineCount());
|
||||
const maxColumn = model!.getLineMaxColumn(lineNumber);
|
||||
const startColumn = Math.min(Math.max(1, diagnostic.column), maxColumn);
|
||||
const endLine = Math.min(
|
||||
Math.max(lineNumber, diagnostic.endLine ?? lineNumber),
|
||||
model!.getLineCount()
|
||||
);
|
||||
const endMaxColumn = model!.getLineMaxColumn(endLine);
|
||||
const endColumn = Math.min(
|
||||
Math.max(startColumn, diagnostic.endColumn ?? startColumn + 1),
|
||||
endMaxColumn
|
||||
);
|
||||
return {
|
||||
severity:
|
||||
diagnostic.severity === 'error'
|
||||
? monaco!.MarkerSeverity.Error
|
||||
: monaco!.MarkerSeverity.Warning,
|
||||
message: diagnostic.message,
|
||||
source: 'EZPP Svelte layer',
|
||||
startLineNumber: lineNumber,
|
||||
startColumn,
|
||||
endLineNumber: endLine,
|
||||
endColumn: Math.max(startColumn + 1, endColumn),
|
||||
};
|
||||
});
|
||||
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
|
||||
errorCount = all.filter((diagnostic) => diagnostic.severity === 'error').length;
|
||||
warningCount = all.filter((diagnostic) => diagnostic.severity === 'warning').length;
|
||||
diagnostics = all;
|
||||
};
|
||||
|
||||
const validate = async (source: string) => {
|
||||
const token = ++validationToken;
|
||||
checking = true;
|
||||
const analysis = await analyzeThemeSvelteSource(source, 'ThemeLayer.svelte');
|
||||
if (token !== validationToken) return;
|
||||
checking = false;
|
||||
syncDiagnostics(analysis);
|
||||
};
|
||||
|
||||
const scheduleValidation = (source: string, immediate = false) => {
|
||||
if (validateTimer) clearTimeout(validateTimer);
|
||||
checking = true;
|
||||
validateTimer = setTimeout(
|
||||
() => {
|
||||
void validate(source);
|
||||
},
|
||||
immediate ? 0 : 350
|
||||
);
|
||||
};
|
||||
|
||||
const updateStats = (source: string) => {
|
||||
lineCount = Math.max(1, source.split('\n').length);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const next = value;
|
||||
if (!model || !monaco || next === currentValue) return;
|
||||
currentValue = next;
|
||||
model.setValue(next);
|
||||
updateStats(next);
|
||||
scheduleValidation(next);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!expanded) return;
|
||||
requestAnimationFrame(() => editor?.layout());
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let disposeIntellisense: (() => void) | undefined;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { default: EditorWorker } =
|
||||
await import('monaco-editor/editor/editor.worker.js?worker');
|
||||
if (disposed) return;
|
||||
|
||||
type WorkerHost = typeof globalThis & {
|
||||
MonacoEnvironment?: { getWorker: () => Worker };
|
||||
};
|
||||
(globalThis as WorkerHost).MonacoEnvironment ??= {
|
||||
getWorker: () => new EditorWorker(),
|
||||
};
|
||||
|
||||
const api = await import('monaco-editor');
|
||||
if (disposed) return;
|
||||
|
||||
monaco = api;
|
||||
ensureLanguage(api);
|
||||
if (!host) return;
|
||||
|
||||
currentValue = value;
|
||||
updateStats(currentValue);
|
||||
model = api.editor.createModel(currentValue, LANGUAGE_ID);
|
||||
editor = api.editor.create(host, {
|
||||
model,
|
||||
theme: THEME_ID,
|
||||
ariaLabel: 'Custom Svelte component source',
|
||||
automaticLayout: true,
|
||||
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: 18,
|
||||
fontLigatures: true,
|
||||
lineNumbers: 'on',
|
||||
lineNumbersMinChars: 3,
|
||||
glyphMargin: true,
|
||||
folding: true,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
wrappingIndent: 'indent',
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: 'line',
|
||||
fixedOverflowWidgets: true,
|
||||
quickSuggestions: { other: true, comments: false, strings: true },
|
||||
suggestOnTriggerCharacters: true,
|
||||
suggest: { showWords: false, preview: true },
|
||||
wordBasedSuggestions: 'off',
|
||||
bracketPairColorization: { enabled: true },
|
||||
guides: { bracketPairs: true, indentation: true },
|
||||
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
const next = model?.getValue() ?? '';
|
||||
currentValue = next;
|
||||
updateStats(next);
|
||||
scheduleValidation(next);
|
||||
onchange?.(next);
|
||||
});
|
||||
editor.onDidChangeCursorPosition((event) => {
|
||||
cursorLine = event.position.lineNumber;
|
||||
cursorColumn = event.position.column;
|
||||
});
|
||||
|
||||
disposeIntellisense = registerIntellisense(api);
|
||||
ready = true;
|
||||
scheduleValidation(currentValue, true);
|
||||
editor.focus();
|
||||
} catch (error) {
|
||||
console.error('Could not load the Monaco Svelte editor', error);
|
||||
loadError = error instanceof Error ? error.message : String(error);
|
||||
scheduleValidation(value, true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (validateTimer) clearTimeout(validateTimer);
|
||||
disposeIntellisense?.();
|
||||
editor?.dispose();
|
||||
model?.dispose();
|
||||
editor = undefined;
|
||||
model = undefined;
|
||||
monaco = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
requestAnimationFrame(() => {
|
||||
editor?.layout();
|
||||
editor?.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape' && expanded) {
|
||||
event.preventDefault();
|
||||
expanded = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
|
||||
aria-label="Close expanded component editor"
|
||||
onclick={() => (expanded = false)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
|
||||
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
|
||||
: 'relative rounded-md'}"
|
||||
style={expanded ? '' : `height:${height}px`}
|
||||
>
|
||||
<div
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
|
||||
>
|
||||
<Component class="size-3 shrink-0 text-primary" />
|
||||
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
|
||||
Svelte component
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-1.5">
|
||||
{#if loadError}
|
||||
<span class="text-[8px] text-destructive">Editor unavailable</span>
|
||||
{:else if checking}
|
||||
<span class="text-[8px] text-muted-foreground">Checking…</span>
|
||||
{:else if errorCount > 0}
|
||||
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
|
||||
<CircleAlert class="size-2.5" />
|
||||
{errorCount}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[8px] text-emerald-400">Ready</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
|
||||
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
|
||||
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
|
||||
onclick={toggleExpanded}
|
||||
>
|
||||
{#if expanded}
|
||||
<Minimize2 class="size-3" />
|
||||
{:else}
|
||||
<Maximize2 class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||
title="Close editor"
|
||||
aria-label="Close expanded editor"
|
||||
onclick={() => (expanded = false)}
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<textarea
|
||||
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
|
||||
spellcheck={false}
|
||||
{value}
|
||||
placeholder={THEME_SVELTE_COMPONENT_PLACEHOLDER}
|
||||
oninput={(event) => {
|
||||
const next = (event.currentTarget as HTMLTextAreaElement).value;
|
||||
onchange?.(next);
|
||||
scheduleValidation(next);
|
||||
}}
|
||||
></textarea>
|
||||
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
|
||||
Monaco could not load: {loadError}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div bind:this={host} class="absolute inset-0"></div>
|
||||
{#if !ready}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
|
||||
>
|
||||
Loading editor…
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if diagnostics.length > 0}
|
||||
<div class="max-h-24 shrink-0 overflow-y-auto border-t border-theme-800 bg-theme-950/70">
|
||||
{#each diagnostics.slice(0, 8) as diagnostic, index (index)}
|
||||
<div
|
||||
class="flex items-start gap-1.5 border-b border-theme-800/60 px-2 py-1 last:border-b-0"
|
||||
>
|
||||
<CircleAlert
|
||||
class="mt-px size-2.5 shrink-0 {diagnostic.severity === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-amber-300'}"
|
||||
/>
|
||||
<p class="min-w-0 flex-1 text-[8px] leading-relaxed text-theme-100">
|
||||
{diagnostic.message}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 font-mono text-[8px] text-muted-foreground hover:text-primary"
|
||||
title="Jump to this line"
|
||||
onclick={() => {
|
||||
editor?.setPosition({ lineNumber: diagnostic.line, column: diagnostic.column });
|
||||
editor?.revealLineInCenter(diagnostic.line);
|
||||
editor?.focus();
|
||||
}}
|
||||
>
|
||||
{diagnostic.line}:{diagnostic.column}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
|
||||
>
|
||||
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
|
||||
<span>Ln {cursorLine}, Col {cursorColumn}</span>
|
||||
<span class="ml-auto">
|
||||
{#if checking}
|
||||
Checking…
|
||||
{:else if errorCount > 0}
|
||||
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
|
||||
{#if warningCount > 0}
|
||||
<span class="text-amber-300">
|
||||
· {warningCount} warning{warningCount === 1 ? '' : 's'}</span
|
||||
>
|
||||
{/if}
|
||||
{:else if warningCount > 0}
|
||||
<span class="text-amber-300">{warningCount} warning{warningCount === 1 ? '' : 's'}</span>
|
||||
{:else}
|
||||
No errors
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { themeClassTokens } from '$lib/themeV2';
|
||||
import { Code2, RotateCcw } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
idPrefix,
|
||||
value,
|
||||
onchange,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
value?: string;
|
||||
onchange: (value: string | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
const invalidTokens = $derived(
|
||||
typeof value === 'string'
|
||||
? value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter((token) => themeClassTokens(token).length === 0)
|
||||
: []
|
||||
);
|
||||
|
||||
function setValue(raw: string) {
|
||||
onchange(raw.trim() ? raw : undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet resetAction()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-1.5 text-[10px] text-muted-foreground"
|
||||
disabled={!value}
|
||||
onclick={() => onchange(undefined)}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="CSS classes"
|
||||
icon={Code2}
|
||||
open={false}
|
||||
actions={resetAction}
|
||||
contentClass="space-y-2 pt-2.5"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<Label for={`${idPrefix}-css-classes`} class={label}>Classes</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-css-classes`}
|
||||
class="h-8 border-theme-800 bg-theme-950 font-mono text-xs text-theme-50"
|
||||
value={value ?? ''}
|
||||
placeholder="glow-card hero-title"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
oninput={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||
Separate multiple classes with spaces and style them from the theme's Custom CSS, for example
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">.glow-card</code>.
|
||||
</p>
|
||||
|
||||
{#if invalidTokens.length > 0}
|
||||
<p class="text-[10px] leading-relaxed text-amber-400">
|
||||
Ignored: <span class="font-mono">{invalidTokens.join(' ')}</span>. Use letters, numbers,
|
||||
dashes and underscores.
|
||||
</p>
|
||||
{/if}
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { compileThemeSvelte } from '$lib/themeSvelte';
|
||||
import { themeAssetUrl, type ThemeV2ScriptValue } from '$lib/themeV2';
|
||||
import { TriangleAlert } from '@lucide/svelte';
|
||||
import { mount, unmount } from 'svelte';
|
||||
|
||||
let {
|
||||
source,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
variables = {},
|
||||
screen = 'home',
|
||||
layerId,
|
||||
layerName,
|
||||
width,
|
||||
height,
|
||||
themeName = '',
|
||||
themeAuthor = '',
|
||||
accentColor = '#ff006e',
|
||||
interactive = false,
|
||||
}: {
|
||||
source: string;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
variables?: Record<string, ThemeV2ScriptValue>;
|
||||
screen?: string;
|
||||
layerId: string;
|
||||
layerName?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
themeName?: string;
|
||||
themeAuthor?: string;
|
||||
accentColor?: string;
|
||||
interactive?: boolean;
|
||||
} = $props();
|
||||
|
||||
type ComponentInstance = ReturnType<typeof mount>;
|
||||
|
||||
let host = $state<HTMLDivElement>();
|
||||
let error = $state('');
|
||||
let loading = $state(true);
|
||||
let instance: ComponentInstance | undefined;
|
||||
let generation = 0;
|
||||
|
||||
// A $state proxy can be passed to mount() so $props() in the custom
|
||||
// component remains reactive when a logic rule changes a variable.
|
||||
const componentProps = $state<Record<string, unknown>>({
|
||||
variables: {},
|
||||
screen: 'home',
|
||||
layer: { id: '', name: undefined, width: 0, height: 0 },
|
||||
theme: { name: '', author: '', accentColor: '#ff006e' },
|
||||
asset: (path: string) => path,
|
||||
});
|
||||
|
||||
const errorMessage = (value: unknown) =>
|
||||
value instanceof Error ? `${value.name}: ${value.message}` : String(value);
|
||||
|
||||
$effect(() => {
|
||||
componentProps.variables = { ...variables };
|
||||
componentProps.screen = screen;
|
||||
componentProps.layer = { id: layerId, name: layerName, width, height };
|
||||
componentProps.theme = { name: themeName, author: themeAuthor, accentColor };
|
||||
componentProps.asset = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const current = ++generation;
|
||||
const currentSource = source;
|
||||
let mounted: ComponentInstance | undefined;
|
||||
loading = true;
|
||||
|
||||
void (async () => {
|
||||
const result = await compileThemeSvelte(currentSource, `layer-${layerId}.svelte`);
|
||||
if (current !== generation || !host) return;
|
||||
|
||||
if (!result.component) {
|
||||
error = result.errors[0]?.message ?? 'The custom component could not be compiled.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
mounted = mount(result.component, { target: host, props: componentProps });
|
||||
instance = mounted;
|
||||
error = '';
|
||||
} catch (mountError) {
|
||||
error = errorMessage(mountError);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
// Invalidate a compile that is still in flight so it cannot mount into
|
||||
// a host that has already been removed with this layer.
|
||||
generation += 1;
|
||||
loading = false;
|
||||
if (mounted) {
|
||||
void unmount(mounted, { outro: false });
|
||||
if (instance === mounted) instance = undefined;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!interactive || !error) return;
|
||||
console.error(`[theme] Svelte layer ${layerId} failed`, error);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full">
|
||||
<div bind:this={host} class="h-full w-full"></div>
|
||||
|
||||
{#if interactive && loading}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border border-dashed border-theme-700 bg-theme-950/65 text-[10px] text-muted-foreground"
|
||||
>
|
||||
Compiling Svelte…
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if interactive && error}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-1.5 overflow-hidden rounded-md border border-destructive/50 bg-destructive/12 p-3 text-center"
|
||||
>
|
||||
<TriangleAlert class="size-4 shrink-0 text-destructive" />
|
||||
<p class="text-[10px] font-semibold text-destructive">Svelte layer failed</p>
|
||||
<p class="line-clamp-4 font-mono text-[9px] leading-relaxed text-destructive/90">{error}</p>
|
||||
<p class="text-[8px] text-muted-foreground">Fix the error in the layer inspector.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||
import type { ThemeV2Filters } from '$lib/themeV2';
|
||||
|
||||
let {
|
||||
filters,
|
||||
onchange,
|
||||
}: {
|
||||
filters?: ThemeV2Filters;
|
||||
onchange: (filters: ThemeV2Filters) => void;
|
||||
} = $props();
|
||||
|
||||
type Control = {
|
||||
key: keyof ThemeV2Filters;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
fallback: number;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
const controls: Control[] = [
|
||||
{ key: 'blur', label: 'Blur', min: 0, max: 40, step: 1, fallback: 0, unit: 'px' },
|
||||
{ key: 'brightness', label: 'Brightness', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
|
||||
{ key: 'contrast', label: 'Contrast', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
|
||||
{ key: 'saturate', label: 'Saturation', min: 0, max: 4, step: 0.05, fallback: 1, unit: '' },
|
||||
{
|
||||
key: 'hueRotate',
|
||||
label: 'Hue rotate',
|
||||
min: -360,
|
||||
max: 360,
|
||||
step: 1,
|
||||
fallback: 0,
|
||||
unit: 'deg',
|
||||
},
|
||||
{ key: 'grayscale', label: 'Grayscale', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
|
||||
{ key: 'sepia', label: 'Sepia', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
|
||||
{ key: 'invert', label: 'Invert', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
|
||||
];
|
||||
|
||||
const valueOf = (control: Control) => filters?.[control.key] ?? control.fallback;
|
||||
const display = (control: Control) => {
|
||||
const value = valueOf(control);
|
||||
return `${control.step < 1 ? value.toFixed(2).replace(/\.?0+$/, '') : Math.round(value)}${control.unit}`;
|
||||
};
|
||||
const isDefault = $derived(controls.every((control) => valueOf(control) === control.fallback));
|
||||
|
||||
function setValue(key: keyof ThemeV2Filters, value: number) {
|
||||
onchange({ ...(filters ?? {}), [key]: value });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet filterActions()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-1.5 text-[10px] text-muted-foreground"
|
||||
disabled={isDefault}
|
||||
onclick={() => onchange({})}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="Filters"
|
||||
open={false}
|
||||
actions={filterActions}
|
||||
contentClass="space-y-2.5 pt-2.5"
|
||||
>
|
||||
<div class="space-y-2.5">
|
||||
{#each controls as control (control.key)}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] text-muted-foreground">{control.label}</span>
|
||||
<span class="font-mono text-[9px] text-theme-100">{display(control)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
value={valueOf(control)}
|
||||
onValueChange={(value) => setValue(control.key, value)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
|
||||
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
|
||||
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import ThemeTransformControls from '$lib/components/theme/ThemeTransformControls.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import {
|
||||
type ThemeV2Behavior,
|
||||
type ThemeV2BehaviorTarget,
|
||||
type ThemeV2Element,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2MaskShape,
|
||||
} from '$lib/themeV2';
|
||||
import { Boxes, RefreshCw, Ungroup } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
group,
|
||||
behaviors,
|
||||
elements,
|
||||
groups,
|
||||
variables = [],
|
||||
focusToken = 0,
|
||||
onungroup,
|
||||
onpickmask,
|
||||
onaddbehavior,
|
||||
ondeleterule,
|
||||
}: {
|
||||
group: ThemeV2Group;
|
||||
behaviors: ThemeV2Behavior[];
|
||||
elements: ThemeV2Element[];
|
||||
groups: ThemeV2Group[];
|
||||
variables?: string[];
|
||||
focusToken?: number;
|
||||
onungroup: () => void;
|
||||
onpickmask: () => void;
|
||||
onaddbehavior: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
|
||||
ondeleterule: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 p-3">
|
||||
<div class="flex items-center gap-2 border-b border-theme-800 pb-2.5">
|
||||
<div class="flex size-8 items-center justify-center rounded-md bg-primary/20 text-primary">
|
||||
<Boxes class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-semibold text-theme-50">Group</p>
|
||||
<p class="truncate font-mono text-[10px] text-muted-foreground">{group.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemeSection title="Group" bordered={false} contentClass="space-y-3 pt-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="group-name" class={label}>Group name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
class={field}
|
||||
value={group.name ?? ''}
|
||||
placeholder="Group name"
|
||||
oninput={(event) => (group.name = (event.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Opacity</Label>
|
||||
<span class="text-[10px] text-muted-foreground"
|
||||
>{Math.round((group.opacity ?? 1) * 100)}%</span
|
||||
>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={group.opacity ?? 1}
|
||||
onValueChange={(value) => (group.opacity = value)}
|
||||
/>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeTransformControls
|
||||
transform={group}
|
||||
idPrefix="group"
|
||||
includeTranslate
|
||||
onchange={(patch) => Object.assign(group, patch)}
|
||||
/>
|
||||
|
||||
<ThemeSection
|
||||
title="Group state"
|
||||
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id="group-hidden"
|
||||
checked={group.hidden === true}
|
||||
onCheckedChange={(checked) => (group.hidden = checked === true)}
|
||||
/>
|
||||
<Label for="group-hidden" class="text-[10px] font-normal text-muted-foreground">Hidden</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id="group-locked"
|
||||
checked={group.locked === true}
|
||||
onCheckedChange={(checked) => (group.locked = checked === true)}
|
||||
/>
|
||||
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeCssClasses
|
||||
idPrefix="group"
|
||||
value={group.cssClasses}
|
||||
onchange={(value) => (group.cssClasses = value)}
|
||||
/>
|
||||
|
||||
<ThemeLinkedLogic
|
||||
target={{ type: 'group', id: group.id }}
|
||||
{behaviors}
|
||||
{elements}
|
||||
{groups}
|
||||
{variables}
|
||||
{focusToken}
|
||||
onadd={onaddbehavior}
|
||||
ondelete={ondeleterule}
|
||||
/>
|
||||
|
||||
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
|
||||
<div class="space-y-1">
|
||||
<Label for="group-mask-shape" class={label}>Clip shape</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={group.mask?.shape ?? 'none'}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
if (value === 'none') {
|
||||
group.mask = undefined;
|
||||
return;
|
||||
}
|
||||
group.mask = {
|
||||
shape: value as ThemeV2MaskShape,
|
||||
radius: group.mask?.radius ?? 24,
|
||||
sides: group.mask?.sides ?? 6,
|
||||
innerRadius: group.mask?.innerRadius ?? 0.5,
|
||||
src: group.mask?.src,
|
||||
fit: group.mask?.fit ?? 'cover',
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="group-mask-shape"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{group.mask?.shape ?? 'None'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="none" label="None" />
|
||||
<Select.Item value="rectangle" label="Rectangle" />
|
||||
<Select.Item value="ellipse" label="Ellipse" />
|
||||
<Select.Item value="polygon" label="Polygon" />
|
||||
<Select.Item value="star" label="Star" />
|
||||
<Select.Item value="image" label="Image alpha" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{#if group.mask?.shape === 'polygon' || group.mask?.shape === 'star'}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="group-mask-sides" class={label}>Points</Label>
|
||||
<Input
|
||||
id="group-mask-sides"
|
||||
class={field}
|
||||
type="number"
|
||||
min="3"
|
||||
max="24"
|
||||
value={group.mask.sides ?? 6}
|
||||
oninput={(event) => {
|
||||
if (group.mask) {
|
||||
group.mask.sides = Number((event.currentTarget as HTMLInputElement).value) || 3;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if group.mask.shape === 'star'}
|
||||
<div class="space-y-1">
|
||||
<Label for="group-mask-inner" class={label}>Inner radius</Label>
|
||||
<Input
|
||||
id="group-mask-inner"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={group.mask.innerRadius ?? 0.5}
|
||||
oninput={(event) => {
|
||||
if (group.mask) {
|
||||
group.mask.innerRadius =
|
||||
Number((event.currentTarget as HTMLInputElement).value) || 0.5;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if group.mask?.shape === 'image'}
|
||||
<div class="space-y-2 rounded-md border border-theme-800 bg-black/20 p-2">
|
||||
<p class="truncate font-mono text-[10px] text-theme-100">
|
||||
{group.mask.src ?? 'No mask image yet'}
|
||||
</p>
|
||||
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onpickmask}>
|
||||
<RefreshCw class="size-3.5" />
|
||||
Choose mask image
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-[9px] leading-relaxed text-muted-foreground">
|
||||
The mask clips every layer in this group. Image masks use the picture's alpha channel.
|
||||
</p>
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeFilterControls filters={group.filters} onchange={(filters) => (group.filters = filters)} />
|
||||
|
||||
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onungroup}>
|
||||
<Ungroup class="size-3.5" />
|
||||
Ungroup group
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { THEME_ICON_BODIES } from '$lib/themeIconData';
|
||||
import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons';
|
||||
|
||||
let {
|
||||
name,
|
||||
set = DEFAULT_THEME_ICON_SET,
|
||||
size = '1em',
|
||||
class: className = '',
|
||||
}: {
|
||||
name: ThemeIconName;
|
||||
set?: ThemeIconSet;
|
||||
size?: number | string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const icon = $derived(
|
||||
THEME_ICON_BODIES[set]?.[name] ?? THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET][name]
|
||||
);
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={`0 0 ${icon.width} ${icon.height}`}
|
||||
width={size}
|
||||
height={size}
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html icon.body}
|
||||
</svg>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { themeBehaviorSummary, themeGroupInteractiveCount } from '$lib/themeLogic';
|
||||
import type {
|
||||
ThemeV2Behavior,
|
||||
ThemeV2BehaviorTarget,
|
||||
ThemeV2Element,
|
||||
ThemeV2Group,
|
||||
} from '$lib/themeV2';
|
||||
import { AlertTriangle, ChevronDown, Plus, Zap } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
target,
|
||||
behaviors,
|
||||
elements,
|
||||
groups,
|
||||
variables = [],
|
||||
focusToken = 0,
|
||||
onadd,
|
||||
ondelete,
|
||||
}: {
|
||||
target: ThemeV2BehaviorTarget;
|
||||
behaviors: ThemeV2Behavior[];
|
||||
elements: ThemeV2Element[];
|
||||
groups: ThemeV2Group[];
|
||||
variables?: string[];
|
||||
focusToken?: number;
|
||||
onadd: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
|
||||
ondelete: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let selectedId = $state<string | undefined>(undefined);
|
||||
let section = $state<HTMLElement>();
|
||||
let lastFocusToken = 0;
|
||||
|
||||
const linkedBehaviors = $derived(
|
||||
behaviors.filter(
|
||||
(behavior) => behavior.target?.type === target.type && behavior.target?.id === target.id
|
||||
)
|
||||
);
|
||||
const pointerRuleCount = $derived(
|
||||
linkedBehaviors.filter(
|
||||
(behavior) =>
|
||||
behavior.trigger.type === 'click' ||
|
||||
behavior.trigger.type === 'hover' ||
|
||||
behavior.trigger.type === 'leave'
|
||||
).length
|
||||
);
|
||||
const interactiveTargets = $derived(
|
||||
target.type === 'group'
|
||||
? themeGroupInteractiveCount(groups, elements, target.id)
|
||||
: elements.find((element) => element.id === target.id)?.interactive
|
||||
? 1
|
||||
: 0
|
||||
);
|
||||
const needsInteractive = $derived(pointerRuleCount > 0 && interactiveTargets === 0);
|
||||
|
||||
$effect(() => {
|
||||
if (!focusToken || focusToken === lastFocusToken) return;
|
||||
lastFocusToken = focusToken;
|
||||
open = true;
|
||||
if (linkedBehaviors.length === 1) selectedId = linkedBehaviors[0].id;
|
||||
requestAnimationFrame(() => section?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
||||
});
|
||||
|
||||
function addRule() {
|
||||
const behavior = onadd(target);
|
||||
open = true;
|
||||
if (behavior) selectedId = behavior.id;
|
||||
}
|
||||
|
||||
function deleteRule(id: string) {
|
||||
ondelete(id);
|
||||
if (selectedId === id) selectedId = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section bind:this={section} class="border-t border-theme-800 pt-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-theme-900/60"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Zap class="size-3.5 shrink-0 text-primary" />
|
||||
<span class="text-[11px] font-semibold text-theme-100">Logic</span>
|
||||
{#if linkedBehaviors.length > 0}
|
||||
<span class="rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||
{linkedBehaviors.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if needsInteractive}
|
||||
<span title="A linked pointer rule needs an interactive layer">
|
||||
<AlertTriangle class="size-3 text-amber-300" />
|
||||
</span>
|
||||
{/if}
|
||||
<ChevronDown
|
||||
class="ml-auto size-3 shrink-0 text-muted-foreground transition-transform {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 gap-1 px-1.5 text-[9px]"
|
||||
title="Add a rule linked to this layer or group"
|
||||
onclick={addRule}
|
||||
>
|
||||
<Plus class="size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
{#if linkedBehaviors.length === 0}
|
||||
<p
|
||||
class="rounded-md border border-dashed border-theme-800 px-2 py-1.5 text-[9px] leading-relaxed text-muted-foreground"
|
||||
>
|
||||
No rules linked yet. Add one to react to clicks, hovers, timers or screen changes.
|
||||
</p>
|
||||
{:else}
|
||||
{#each linkedBehaviors as behavior (behavior.id)}
|
||||
<div class="overflow-hidden rounded-md border border-theme-800 bg-black/20">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 px-2 py-1.5 text-left transition-colors hover:bg-theme-900/60"
|
||||
aria-expanded={selectedId === behavior.id}
|
||||
onclick={() => (selectedId = selectedId === behavior.id ? undefined : behavior.id)}
|
||||
>
|
||||
<span
|
||||
class="size-1.5 shrink-0 rounded-full {behavior.enabled === false
|
||||
? 'bg-theme-600'
|
||||
: 'bg-emerald-400'}"
|
||||
></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="truncate text-[10px] font-medium text-theme-100">
|
||||
{behavior.name?.trim() || 'Rule'}
|
||||
</span>
|
||||
{#if behavior.once}
|
||||
<span class="rounded bg-theme-900 px-1 py-0.5 text-[8px] text-muted-foreground">
|
||||
once
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="mt-0.5 block truncate text-[9px] text-muted-foreground">
|
||||
{themeBehaviorSummary(behavior, elements, groups)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="size-3 shrink-0 text-muted-foreground transition-transform {selectedId ===
|
||||
behavior.id
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if selectedId === behavior.id}
|
||||
<div class="border-t border-theme-800 bg-theme-950/60">
|
||||
<ThemeBehaviorInspector
|
||||
{behavior}
|
||||
{elements}
|
||||
{groups}
|
||||
{variables}
|
||||
embedded
|
||||
ondelete={() => deleteRule(behavior.id)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if needsInteractive}
|
||||
<div
|
||||
class="flex items-start gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/5 p-1.5"
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 size-3 shrink-0 text-amber-300" />
|
||||
<p class="text-[9px] leading-relaxed text-amber-100/80">
|
||||
{#if target.type === 'group'}
|
||||
Mark at least one layer in this group as <strong>Interactive</strong>.
|
||||
{:else}
|
||||
Enable <strong>Interactive</strong> for this layer.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import { AlertTriangle, Zap } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
count,
|
||||
warning = false,
|
||||
onopen,
|
||||
}: {
|
||||
count: number;
|
||||
warning?: boolean;
|
||||
onopen: () => void;
|
||||
} = $props();
|
||||
|
||||
const title = $derived(
|
||||
warning
|
||||
? `${count} linked logic rule${count === 1 ? '' : 's'} · needs an interactive layer · Open in inspector`
|
||||
: `${count} linked logic rule${count === 1 ? '' : 's'} · Open in inspector`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if count > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="-m-0.5 shrink-0 cursor-pointer rounded-full p-0.5 transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
{title}
|
||||
aria-label={title}
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
onopen();
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="pointer-events-none h-4 gap-0.5 border px-1 py-0 text-[8px] font-semibold leading-none {warning
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
|
||||
: 'border-primary/25 bg-primary/10 text-primary'}"
|
||||
>
|
||||
{#if warning}
|
||||
<AlertTriangle class="size-2.5" />
|
||||
{:else}
|
||||
<Zap class="size-2.5" />
|
||||
{/if}
|
||||
{count}
|
||||
</Badge>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import lottie, { type AnimationItem } from 'lottie-web';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
src,
|
||||
loop = true,
|
||||
autoplay = true,
|
||||
speed = 1,
|
||||
}: {
|
||||
src: string;
|
||||
loop?: boolean;
|
||||
autoplay?: boolean;
|
||||
speed?: number;
|
||||
} = $props();
|
||||
|
||||
let container = $state<HTMLDivElement | undefined>(undefined);
|
||||
let animation: AnimationItem | undefined;
|
||||
let reducedMotion = false;
|
||||
|
||||
onMount(() => {
|
||||
if (!container || !src) return;
|
||||
reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
let cancelled = false;
|
||||
|
||||
const start = (animationData?: unknown) => {
|
||||
if (cancelled || !container) return;
|
||||
try {
|
||||
animation = lottie.loadAnimation({
|
||||
container,
|
||||
renderer: 'svg',
|
||||
loop,
|
||||
autoplay: autoplay && !reducedMotion,
|
||||
...(animationData ? { animationData } : { path: src }),
|
||||
rendererSettings: { preserveAspectRatio: 'xMidYMid meet' },
|
||||
});
|
||||
animation.setSpeed(speed);
|
||||
if (reducedMotion) {
|
||||
animation.addEventListener('DOMLoaded', () => animation?.goToAndStop(0, true));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load the Lottie layer', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetching the JSON ourselves works with Tauri's asset protocols, where
|
||||
// lottie-web's internal XHR can be blocked. Fall back to its own loader.
|
||||
void fetch(src)
|
||||
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
|
||||
.then((data) => start(data))
|
||||
.catch(() => start());
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!animation) return;
|
||||
animation.loop = loop;
|
||||
animation.setSpeed(speed);
|
||||
if (autoplay && !reducedMotion) animation.play();
|
||||
else animation.pause();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
animation?.destroy();
|
||||
animation = undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="h-full w-full" aria-hidden="true"></div>
|
||||
@@ -0,0 +1,890 @@
|
||||
<script lang="ts">
|
||||
import { isThemeClockVariable, THEME_CLOCK_VARIABLE_NAMES } from '$lib/themeClock';
|
||||
import { parseThemeScript } from '$lib/themeScriptLang';
|
||||
import type { ThemeV2Element, ThemeV2Group, ThemeV2ScriptScreen } from '$lib/themeV2';
|
||||
import { Braces, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type MonacoApi = typeof import('monaco-editor');
|
||||
|
||||
let {
|
||||
value = '',
|
||||
onchange,
|
||||
variables = [],
|
||||
elements = [],
|
||||
groups = [],
|
||||
height = 280,
|
||||
}: {
|
||||
value?: string;
|
||||
onchange?: (value: string) => void;
|
||||
variables?: string[];
|
||||
elements?: ThemeV2Element[];
|
||||
groups?: ThemeV2Group[];
|
||||
height?: number;
|
||||
} = $props();
|
||||
|
||||
const LANGUAGE_ID = 'ezpp-theme-script';
|
||||
const THEME_ID = 'ezpp-theme-script-dark';
|
||||
const MARKER_OWNER = 'ezpp-theme-script';
|
||||
|
||||
const screens: ThemeV2ScriptScreen[] = ['home', 'themes', 'settings', 'login'];
|
||||
|
||||
const statementDocs: Record<string, string> = {
|
||||
let: 'Declare a theme variable.\n\n`let score = 0;`',
|
||||
const: 'Declare a value that will not be reassigned.\n\n`const bonus = 10;`',
|
||||
var: 'Declare a theme variable using the older JavaScript keyword.\n\n`var score = 0;`',
|
||||
if: 'Run a block only when a condition is true.\n\n`if (score >= 10) { ... }`',
|
||||
else: 'Run a block when the `if` condition is false.\n\n`else { ... }`',
|
||||
for: 'Run a bounded loop.\n\n`for (let i = 0; i < 3; i++) { ... }`',
|
||||
while: 'Run while a condition stays true. Loops stop after 1,000 iterations.',
|
||||
true: 'Boolean true.',
|
||||
false: 'Boolean false.',
|
||||
screen: 'The current launcher screen, such as `"home"` or `"themes"`.',
|
||||
Math: 'JavaScript math helpers such as `Math.floor(value)` and `Math.random()`.',
|
||||
};
|
||||
|
||||
const actionFunctions: {
|
||||
name: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
documentation: string;
|
||||
}[] = [
|
||||
{
|
||||
name: 'showLayer',
|
||||
label: 'showLayer(id)',
|
||||
insert: 'showLayer(${1:"layer_id"});',
|
||||
documentation: 'Make a layer visible.',
|
||||
},
|
||||
{
|
||||
name: 'hideLayer',
|
||||
label: 'hideLayer(id)',
|
||||
insert: 'hideLayer(${1:"layer_id"});',
|
||||
documentation: 'Hide a layer.',
|
||||
},
|
||||
{
|
||||
name: 'toggleLayer',
|
||||
label: 'toggleLayer(id)',
|
||||
insert: 'toggleLayer(${1:"layer_id"});',
|
||||
documentation: 'Flip a layer between visible and hidden.',
|
||||
},
|
||||
{
|
||||
name: 'showGroup',
|
||||
label: 'showGroup(id)',
|
||||
insert: 'showGroup(${1:"group_id"});',
|
||||
documentation: 'Make a group visible.',
|
||||
},
|
||||
{
|
||||
name: 'hideGroup',
|
||||
label: 'hideGroup(id)',
|
||||
insert: 'hideGroup(${1:"group_id"});',
|
||||
documentation: 'Hide a group.',
|
||||
},
|
||||
{
|
||||
name: 'toggleGroup',
|
||||
label: 'toggleGroup(id)',
|
||||
insert: 'toggleGroup(${1:"group_id"});',
|
||||
documentation: 'Flip a group between visible and hidden.',
|
||||
},
|
||||
{
|
||||
name: 'goTo',
|
||||
label: 'goTo(screen)',
|
||||
insert: 'goTo(${1:"themes"});',
|
||||
documentation: 'Navigate to `home`, `themes`, `settings` or `login`.',
|
||||
},
|
||||
{
|
||||
name: 'navigate',
|
||||
label: 'navigate(screen)',
|
||||
insert: 'navigate(${1:"themes"});',
|
||||
documentation: 'Alias for `goTo`.',
|
||||
},
|
||||
{
|
||||
name: 'openUrl',
|
||||
label: 'openUrl(url)',
|
||||
insert: 'openUrl(${1:"https://example.com"});',
|
||||
documentation: 'Open a website after a confirmation dialog. Click triggers only.',
|
||||
},
|
||||
{
|
||||
name: 'launch',
|
||||
label: 'launch()',
|
||||
insert: 'launch();',
|
||||
documentation: 'Start the normal osu! launch flow. Click triggers only.',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
label: 'stop()',
|
||||
insert: 'stop();',
|
||||
documentation: 'End the current script immediately.',
|
||||
},
|
||||
];
|
||||
|
||||
const functions: {
|
||||
name: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
documentation: string;
|
||||
}[] = [
|
||||
{
|
||||
name: 'min',
|
||||
label: 'min(a, b, ...)',
|
||||
insert: 'min(${1:a}, ${2:b})',
|
||||
documentation: 'Returns the smallest number.',
|
||||
},
|
||||
{
|
||||
name: 'max',
|
||||
label: 'max(a, b, ...)',
|
||||
insert: 'max(${1:a}, ${2:b})',
|
||||
documentation: 'Returns the largest number.',
|
||||
},
|
||||
{
|
||||
name: 'clamp',
|
||||
label: 'clamp(value, min, max)',
|
||||
insert: 'clamp(${1:value}, ${2:0}, ${3:100})',
|
||||
documentation: 'Keeps a number inside a range.',
|
||||
},
|
||||
{
|
||||
name: 'round',
|
||||
label: 'round(value)',
|
||||
insert: 'round(${1:value})',
|
||||
documentation: 'Rounds to the nearest whole number.',
|
||||
},
|
||||
{
|
||||
name: 'floor',
|
||||
label: 'floor(value)',
|
||||
insert: 'floor(${1:value})',
|
||||
documentation: 'Rounds down.',
|
||||
},
|
||||
{
|
||||
name: 'ceil',
|
||||
label: 'ceil(value)',
|
||||
insert: 'ceil(${1:value})',
|
||||
documentation: 'Rounds up.',
|
||||
},
|
||||
{
|
||||
name: 'abs',
|
||||
label: 'abs(value)',
|
||||
insert: 'abs(${1:value})',
|
||||
documentation: 'Returns the absolute value.',
|
||||
},
|
||||
{
|
||||
name: 'random',
|
||||
label: 'random()',
|
||||
insert: 'random()',
|
||||
documentation: 'Returns a random number between 0 and 1.',
|
||||
},
|
||||
{
|
||||
name: 'chance',
|
||||
label: 'chance(percent)',
|
||||
insert: 'chance(${1:25})',
|
||||
documentation: 'Returns true with the given percentage chance.',
|
||||
},
|
||||
{
|
||||
name: 'visible',
|
||||
label: 'visible(layerId)',
|
||||
insert: 'visible(${1:"layer_id"})',
|
||||
documentation: 'Whether a layer is currently visible.',
|
||||
},
|
||||
{
|
||||
name: 'groupVisible',
|
||||
label: 'groupVisible(groupId)',
|
||||
insert: 'groupVisible(${1:"group_id"})',
|
||||
documentation: 'Whether a group is currently visible.',
|
||||
},
|
||||
{
|
||||
name: 'contains',
|
||||
label: 'contains(text, search)',
|
||||
insert: 'contains(${1:text}, ${2:"search"})',
|
||||
documentation: 'Whether text contains a smaller string.',
|
||||
},
|
||||
{
|
||||
name: 'length',
|
||||
label: 'length(text)',
|
||||
insert: 'length(${1:text})',
|
||||
documentation: 'Returns the number of characters in text.',
|
||||
},
|
||||
{
|
||||
name: 'number',
|
||||
label: 'number(value)',
|
||||
insert: 'number(${1:value})',
|
||||
documentation: 'Converts a value to a number.',
|
||||
},
|
||||
{
|
||||
name: 'string',
|
||||
label: 'string(value)',
|
||||
insert: 'string(${1:value})',
|
||||
documentation: 'Converts a value to text.',
|
||||
},
|
||||
{
|
||||
name: 'boolean',
|
||||
label: 'boolean(value)',
|
||||
insert: 'boolean(${1:value})',
|
||||
documentation: 'Converts a value to true or false.',
|
||||
},
|
||||
];
|
||||
|
||||
const snippets: { label: string; insert: string; documentation: string }[] = [
|
||||
{
|
||||
label: 'let … = …;',
|
||||
insert: 'let ${1:name} = ${2:0};',
|
||||
documentation: 'Declare a theme variable.',
|
||||
},
|
||||
{
|
||||
label: 'if (…) { … }',
|
||||
insert: 'if (${1:condition}) {\n\t$0\n}',
|
||||
documentation: 'Run statements when a condition matches.',
|
||||
},
|
||||
{
|
||||
label: 'if (…) { … } else { … }',
|
||||
insert: 'if (${1:condition}) {\n\t$0\n} else {\n\t\n}',
|
||||
documentation: 'Run one of two blocks.',
|
||||
},
|
||||
{
|
||||
label: 'for (…) { … }',
|
||||
insert: 'for (let ${1:i} = 0; ${1:i} < ${2:3}; ${1:i}++) {\n\t$0\n}',
|
||||
documentation: 'Run a bounded loop.',
|
||||
},
|
||||
{
|
||||
label: 'while (…) { … }',
|
||||
insert: 'while (${1:condition}) {\n\t$0\n}',
|
||||
documentation: 'Run while a condition stays true.',
|
||||
},
|
||||
{
|
||||
label: 'clicks += 1;',
|
||||
insert: '${1:clicks} += ${2:1};',
|
||||
documentation: 'Add to a number variable.',
|
||||
},
|
||||
{
|
||||
label: 'showLayer(…);',
|
||||
insert: 'showLayer(${1:"layer_id"});',
|
||||
documentation: 'Make a layer visible.',
|
||||
},
|
||||
{
|
||||
label: 'hideLayer(…);',
|
||||
insert: 'hideLayer(${1:"layer_id"});',
|
||||
documentation: 'Hide a layer.',
|
||||
},
|
||||
{
|
||||
label: 'toggleLayer(…);',
|
||||
insert: 'toggleLayer(${1:"layer_id"});',
|
||||
documentation: 'Flip a layer between visible and hidden.',
|
||||
},
|
||||
{
|
||||
label: 'goTo(…);',
|
||||
insert: 'goTo(${1:"themes"});',
|
||||
documentation: 'Navigate to another launcher screen.',
|
||||
},
|
||||
{
|
||||
label: 'openUrl(…);',
|
||||
insert: 'openUrl(${1:"https://example.com"});',
|
||||
documentation: 'Open a website after a confirmation dialog.',
|
||||
},
|
||||
];
|
||||
|
||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||
let monaco: MonacoApi | undefined;
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
|
||||
let model: Monaco.editor.ITextModel | undefined;
|
||||
let ready = $state(false);
|
||||
let loadError = $state('');
|
||||
let expanded = $state(false);
|
||||
let lineCount = $state(1);
|
||||
let cursorLine = $state(1);
|
||||
let cursorColumn = $state(1);
|
||||
let errorCount = $state(0);
|
||||
let currentValue = '';
|
||||
|
||||
const ensureLanguage = (api: MonacoApi) => {
|
||||
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
|
||||
api.languages.register({ id: LANGUAGE_ID });
|
||||
}
|
||||
|
||||
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
|
||||
comments: { lineComment: '//', blockComment: ['/*', '*/'] },
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')'],
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
indentationRules: {
|
||||
increaseIndentPattern: /\{[^}"']*$/,
|
||||
decreaseIndentPattern: /^\s*\}/,
|
||||
},
|
||||
});
|
||||
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\s+/, 'white'],
|
||||
[/\/\/.*$/, 'comment'],
|
||||
[/\/\*/, { token: 'comment', next: '@comment' }],
|
||||
[/"/, { token: 'string.quote', bracket: '@open', next: '@string' }],
|
||||
[/'/, { token: 'string.quote', bracket: '@open', next: '@singleString' }],
|
||||
[/`/, { token: 'string.quote', bracket: '@open', next: '@templateString' }],
|
||||
[
|
||||
/\b(?:let|const|var|if|else|for|while|do|function|return|break|continue|switch|case|default|new|delete|typeof|instanceof|in|of|class|try|catch|finally|throw|this)\b/,
|
||||
'keyword',
|
||||
],
|
||||
[/\b(?:true|false|null|undefined)\b/, 'keyword'],
|
||||
[
|
||||
/\b(?:min|max|clamp|round|floor|ceil|abs|random|chance|visible|groupVisible|contains|length|number|string|boolean|Number|String|Boolean|showLayer|hideLayer|toggleLayer|showGroup|hideGroup|toggleGroup|goTo|navigate|openUrl|launch|stop|Math)\b/,
|
||||
'predefined',
|
||||
],
|
||||
[/\b(?:screen)\b/, 'constant'],
|
||||
[/\b\d+(?:\.\d+)?\b/, 'number'],
|
||||
[/[A-Za-z_][A-Za-z0-9_]*/, 'identifier'],
|
||||
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|\*\*|[=!<>+\-*/%?:]/, 'operator'],
|
||||
[/[{}()[\]]/, '@brackets'],
|
||||
[/[,;]/, 'delimiter'],
|
||||
[/[^\s]/, 'invalid'],
|
||||
],
|
||||
string: [
|
||||
[/[^"\\]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/"/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
|
||||
],
|
||||
singleString: [
|
||||
[/[^'\\]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/'/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
|
||||
],
|
||||
templateString: [
|
||||
[/[^`\\]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/`/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
|
||||
],
|
||||
comment: [
|
||||
[/[^*/]+/, 'comment'],
|
||||
[/\*\//, { token: 'comment', next: '@pop' }],
|
||||
[/[*/]/, 'comment'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
api.editor.defineTheme(THEME_ID, {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
|
||||
{ token: 'predefined', foreground: '8be9fd' },
|
||||
{ token: 'constant', foreground: 'ffb86c' },
|
||||
{ token: 'identifier', foreground: 'f8f8f2' },
|
||||
{ token: 'number', foreground: 'bd93f9' },
|
||||
{ token: 'string', foreground: 'f1fa8c' },
|
||||
{ token: 'string.escape', foreground: 'ffb86c' },
|
||||
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
|
||||
{ token: 'operator', foreground: 'ff79c6' },
|
||||
{ token: 'invalid', foreground: 'ff5555' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#08080c',
|
||||
'editor.foreground': '#f4f4f5',
|
||||
'editorLineNumber.foreground': '#52525b',
|
||||
'editorLineNumber.activeForeground': '#e4e4e7',
|
||||
'editor.lineHighlightBackground': '#ffffff08',
|
||||
'editor.selectionBackground': '#ff006e33',
|
||||
'editor.inactiveSelectionBackground': '#ff006e22',
|
||||
'editorCursor.foreground': '#ff006e',
|
||||
'editorIndentGuide.background1': '#27272a',
|
||||
'editorIndentGuide.activeBackground1': '#52525b',
|
||||
'editorBracketHighlight.foreground1': '#ff79c6',
|
||||
'editorBracketHighlight.foreground2': '#8be9fd',
|
||||
'editorBracketHighlight.foreground3': '#f1fa8c',
|
||||
'editorGutter.background': '#08080c',
|
||||
'editorWidget.background': '#121218',
|
||||
'editorWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.background': '#121218',
|
||||
'editorSuggestWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.selectedBackground': '#ff006e26',
|
||||
'editorSuggestWidget.highlightForeground': '#ff79c6',
|
||||
'editorHoverWidget.background': '#121218',
|
||||
'editorHoverWidget.border': '#3f3f46',
|
||||
'editorError.foreground': '#f87171',
|
||||
'editorOverviewRuler.errorForeground': '#f87171',
|
||||
'scrollbarSlider.background': '#52525b55',
|
||||
'scrollbarSlider.hoverBackground': '#71717a88',
|
||||
'scrollbarSlider.activeBackground': '#a1a1aa99',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const completionRange = (
|
||||
api: MonacoApi,
|
||||
currentModel: Monaco.editor.ITextModel,
|
||||
position: Monaco.Position
|
||||
) => {
|
||||
const word = currentModel.getWordUntilPosition(position);
|
||||
return new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
);
|
||||
};
|
||||
|
||||
const insideString = (text: string) => {
|
||||
let inside = false;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (text[index] !== '"') continue;
|
||||
let backslashes = 0;
|
||||
for (let check = index - 1; check >= 0 && text[check] === '\\'; check -= 1) {
|
||||
backslashes += 1;
|
||||
}
|
||||
if (backslashes % 2 === 0) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
const registerIntellisense = (api: MonacoApi) => {
|
||||
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
|
||||
triggerCharacters: ['"', '.'],
|
||||
provideCompletionItems(currentModel, position) {
|
||||
const range = completionRange(api, currentModel, position);
|
||||
const line = currentModel.getLineContent(position.lineNumber);
|
||||
const before = line.slice(0, position.column - 1);
|
||||
const suggestions: Monaco.languages.CompletionItem[] = [];
|
||||
|
||||
if (insideString(before)) {
|
||||
const prefix = before.slice(0, before.lastIndexOf('"')).toLowerCase();
|
||||
const wantsScreen = /(?:goTo|navigate)\s*\(\s*$/.test(prefix);
|
||||
const wantsGroup = /(?:showGroup|hideGroup|toggleGroup|groupVisible)\s*\(\s*$/.test(
|
||||
prefix
|
||||
);
|
||||
const wantsLayer = /(?:showLayer|hideLayer|toggleLayer|visible)\s*\(\s*$/.test(prefix);
|
||||
|
||||
if (wantsScreen) {
|
||||
for (const screen of screens) {
|
||||
suggestions.push({
|
||||
label: screen,
|
||||
kind: api.languages.CompletionItemKind.EnumMember,
|
||||
insertText: screen,
|
||||
range,
|
||||
documentation: `Go to the ${screen} screen.`,
|
||||
sortText: `0_${screen}`,
|
||||
});
|
||||
}
|
||||
} else if (wantsGroup) {
|
||||
for (const group of groups) {
|
||||
suggestions.push({
|
||||
label: group.name?.trim() || group.id,
|
||||
detail: group.id,
|
||||
kind: api.languages.CompletionItemKind.Struct,
|
||||
insertText: group.id,
|
||||
range,
|
||||
documentation: `Group id: \`${group.id}\``,
|
||||
sortText: `0_${group.id}`,
|
||||
});
|
||||
}
|
||||
} else if (wantsLayer) {
|
||||
for (const element of elements) {
|
||||
suggestions.push({
|
||||
label: element.name?.trim() || element.id,
|
||||
detail: element.id,
|
||||
kind: api.languages.CompletionItemKind.Field,
|
||||
insertText: element.id,
|
||||
range,
|
||||
documentation: `${element.type} layer id: \`${element.id}\``,
|
||||
sortText: `0_${element.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions, incomplete: false };
|
||||
}
|
||||
|
||||
for (const snippet of snippets) {
|
||||
suggestions.push({
|
||||
label: snippet.label,
|
||||
kind: api.languages.CompletionItemKind.Snippet,
|
||||
insertText: snippet.insert,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: snippet.documentation,
|
||||
sortText: `0_${snippet.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of Object.entries(statementDocs)) {
|
||||
suggestions.push({
|
||||
label: entry[0],
|
||||
kind: api.languages.CompletionItemKind.Keyword,
|
||||
insertText: entry[0],
|
||||
range,
|
||||
documentation: entry[1],
|
||||
sortText: `1_${entry[0]}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const variable of new Set([...THEME_CLOCK_VARIABLE_NAMES, ...variables])) {
|
||||
const clock = isThemeClockVariable(variable);
|
||||
suggestions.push({
|
||||
label: variable,
|
||||
kind: api.languages.CompletionItemKind.Variable,
|
||||
insertText: variable,
|
||||
range,
|
||||
documentation: clock
|
||||
? `Built-in clock value, read-only and refreshed every second. Display it in text layers with \`{${variable}}\`.`
|
||||
: `Theme variable. Assign it with \`${variable} += 1;\` and display it in text layers with \`{${variable}}\`.`,
|
||||
sortText: `${clock ? '1' : '2'}_${variable}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const screen of screens) {
|
||||
suggestions.push({
|
||||
label: `"${screen}"`,
|
||||
kind: api.languages.CompletionItemKind.EnumMember,
|
||||
insertText: `"${screen}"`,
|
||||
range,
|
||||
documentation: 'Screen name for `goTo()` and `screen`.',
|
||||
sortText: `3_${screen}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of actionFunctions) {
|
||||
suggestions.push({
|
||||
label: entry.label,
|
||||
kind: api.languages.CompletionItemKind.Function,
|
||||
insertText: entry.insert,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: entry.documentation,
|
||||
sortText: `1_${entry.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of functions) {
|
||||
suggestions.push({
|
||||
label: entry.label,
|
||||
kind: api.languages.CompletionItemKind.Function,
|
||||
insertText: entry.insert,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: entry.documentation,
|
||||
sortText: `1_${entry.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { suggestions, incomplete: false };
|
||||
},
|
||||
});
|
||||
|
||||
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
|
||||
provideHover(currentModel, position) {
|
||||
const word = currentModel.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
|
||||
let contents = statementDocs[word.word];
|
||||
let kind = 'Keyword';
|
||||
if (!contents) {
|
||||
const entry = [...functions, ...actionFunctions].find(
|
||||
(candidate) => candidate.name === word.word
|
||||
);
|
||||
if (entry) {
|
||||
contents = `**${entry.label}**\n\n${entry.documentation}`;
|
||||
kind = 'Function';
|
||||
}
|
||||
}
|
||||
if (!contents && (isThemeClockVariable(word.word) || variables.includes(word.word))) {
|
||||
contents = isThemeClockVariable(word.word)
|
||||
? `**${word.word}** — built-in clock value\n\nRefreshes every second. Use it in text layers as \`{${word.word}}\` or read it directly in logic.`
|
||||
: `**${word.word}** — theme variable\n\nUse it in text layers as \`{${word.word}}\`.`;
|
||||
kind = 'Variable';
|
||||
}
|
||||
if (!contents) {
|
||||
const element = elements.find((candidate) => candidate.id === word.word);
|
||||
if (element) {
|
||||
contents = `**${element.name?.trim() || element.type}**\n\nLayer id: \`${element.id}\``;
|
||||
kind = 'Layer';
|
||||
}
|
||||
}
|
||||
if (!contents) {
|
||||
const group = groups.find((candidate) => candidate.id === word.word);
|
||||
if (group) {
|
||||
contents = `**${group.name?.trim() || 'Group'}**\n\nGroup id: \`${group.id}\``;
|
||||
kind = 'Group';
|
||||
}
|
||||
}
|
||||
if (!contents) return null;
|
||||
|
||||
return {
|
||||
range: new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
),
|
||||
contents: [{ value: `_${kind}_\n\n${contents}` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
completion.dispose();
|
||||
hover.dispose();
|
||||
};
|
||||
};
|
||||
|
||||
const syncDiagnostics = (source: string) => {
|
||||
if (!monaco || !model) return;
|
||||
const result = parseThemeScript(source);
|
||||
const markers: Monaco.editor.IMarkerData[] = result.errors.map((error) => {
|
||||
const lineNumber = Math.min(Math.max(1, error.line), model!.getLineCount());
|
||||
const maxColumn = model!.getLineMaxColumn(lineNumber);
|
||||
const startColumn = Math.min(Math.max(1, error.column), maxColumn);
|
||||
return {
|
||||
severity: monaco!.MarkerSeverity.Error,
|
||||
message: error.message,
|
||||
source: 'EZPP theme script',
|
||||
startLineNumber: lineNumber,
|
||||
startColumn,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: Math.min(maxColumn, startColumn + 1),
|
||||
};
|
||||
});
|
||||
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
|
||||
errorCount = markers.length;
|
||||
};
|
||||
|
||||
const updateStats = (source: string) => {
|
||||
lineCount = Math.max(1, source.split('\n').length);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const next = value;
|
||||
if (!model || !monaco || next === currentValue) return;
|
||||
currentValue = next;
|
||||
model.setValue(next);
|
||||
updateStats(next);
|
||||
syncDiagnostics(next);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!expanded) return;
|
||||
requestAnimationFrame(() => editor?.layout());
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let disposeIntellisense: (() => void) | undefined;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { default: EditorWorker } =
|
||||
await import('monaco-editor/editor/editor.worker.js?worker');
|
||||
if (disposed) return;
|
||||
|
||||
type WorkerHost = typeof globalThis & {
|
||||
MonacoEnvironment?: { getWorker: () => Worker };
|
||||
};
|
||||
(globalThis as WorkerHost).MonacoEnvironment = {
|
||||
getWorker: () => new EditorWorker(),
|
||||
};
|
||||
|
||||
const api = await import('monaco-editor');
|
||||
if (disposed) return;
|
||||
|
||||
monaco = api;
|
||||
ensureLanguage(api);
|
||||
if (!host) return;
|
||||
|
||||
currentValue = value;
|
||||
updateStats(currentValue);
|
||||
model = api.editor.createModel(currentValue, LANGUAGE_ID);
|
||||
editor = api.editor.create(host, {
|
||||
model,
|
||||
theme: THEME_ID,
|
||||
ariaLabel: 'Advanced theme rule script',
|
||||
automaticLayout: true,
|
||||
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 19,
|
||||
fontLigatures: true,
|
||||
lineNumbers: 'on',
|
||||
lineNumbersMinChars: 3,
|
||||
glyphMargin: true,
|
||||
folding: true,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
wrappingIndent: 'indent',
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: 'line',
|
||||
fixedOverflowWidgets: true,
|
||||
quickSuggestions: { other: true, comments: false, strings: false },
|
||||
suggestOnTriggerCharacters: true,
|
||||
suggest: { showWords: false, preview: true },
|
||||
wordBasedSuggestions: 'off',
|
||||
bracketPairColorization: { enabled: true },
|
||||
guides: { bracketPairs: true, indentation: true },
|
||||
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
const next = model?.getValue() ?? '';
|
||||
currentValue = next;
|
||||
updateStats(next);
|
||||
syncDiagnostics(next);
|
||||
onchange?.(next);
|
||||
});
|
||||
editor.onDidChangeCursorPosition((event) => {
|
||||
cursorLine = event.position.lineNumber;
|
||||
cursorColumn = event.position.column;
|
||||
});
|
||||
|
||||
disposeIntellisense = registerIntellisense(api);
|
||||
syncDiagnostics(currentValue);
|
||||
updateStats(currentValue);
|
||||
ready = true;
|
||||
editor.focus();
|
||||
} catch (error) {
|
||||
console.error('Could not load the Monaco script editor', error);
|
||||
loadError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposeIntellisense?.();
|
||||
editor?.dispose();
|
||||
model?.dispose();
|
||||
editor = undefined;
|
||||
model = undefined;
|
||||
monaco = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
requestAnimationFrame(() => {
|
||||
editor?.layout();
|
||||
editor?.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape' && expanded) {
|
||||
event.preventDefault();
|
||||
expanded = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
|
||||
aria-label="Close expanded script editor"
|
||||
onclick={() => (expanded = false)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
|
||||
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
|
||||
: 'relative rounded-md'}"
|
||||
style={expanded ? '' : `height:${height}px`}
|
||||
>
|
||||
<div
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
|
||||
>
|
||||
<Braces class="size-3 shrink-0 text-primary" />
|
||||
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
|
||||
Advanced script
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-1.5">
|
||||
{#if loadError}
|
||||
<span class="text-[8px] text-destructive">Editor unavailable</span>
|
||||
{:else if errorCount > 0}
|
||||
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
|
||||
<CircleAlert class="size-2.5" />
|
||||
{errorCount}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[8px] text-emerald-400">Ready</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
|
||||
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
|
||||
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
|
||||
onclick={toggleExpanded}
|
||||
>
|
||||
{#if expanded}
|
||||
<Minimize2 class="size-3" />
|
||||
{:else}
|
||||
<Maximize2 class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||
title="Close editor"
|
||||
aria-label="Close expanded editor"
|
||||
onclick={() => (expanded = false)}
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<textarea
|
||||
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
|
||||
spellcheck={false}
|
||||
{value}
|
||||
placeholder={'score += 1;\nif (score >= 3) {\n showLayer("secret");\n}'}
|
||||
oninput={(event) => {
|
||||
const next = (event.currentTarget as HTMLTextAreaElement).value;
|
||||
onchange?.(next);
|
||||
}}
|
||||
></textarea>
|
||||
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
|
||||
Monaco could not load: {loadError}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div bind:this={host} class="absolute inset-0"></div>
|
||||
{#if !ready}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
|
||||
>
|
||||
Loading editor…
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
|
||||
>
|
||||
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
|
||||
<span>Ln {cursorLine}, Col {cursorColumn}</span>
|
||||
<span class="ml-auto">
|
||||
{#if errorCount > 0}
|
||||
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
|
||||
{:else}
|
||||
No errors
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from '@lucide/svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
open = $bindable(true),
|
||||
icon: Icon,
|
||||
badge,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
class: className = '',
|
||||
contentClass = 'space-y-3 pt-2.5',
|
||||
headingClass = 'text-xs font-semibold text-theme-100',
|
||||
bordered = true,
|
||||
}: {
|
||||
title: string;
|
||||
open?: boolean;
|
||||
icon?: Component<{ class?: string }>;
|
||||
badge?: string | number;
|
||||
description?: string;
|
||||
actions?: Snippet;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
headingClass?: string;
|
||||
bordered?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section class="{bordered ? 'border-t border-theme-800 pt-3 ' : ''}{className}">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-0.5 text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon class="size-3.5 shrink-0 text-primary" />
|
||||
{/if}
|
||||
<span class="truncate {headingClass}">{title}</span>
|
||||
{#if badge !== undefined && badge !== ''}
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
{/if}
|
||||
<ChevronDown
|
||||
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if actions}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class={contentClass}>
|
||||
{#if description}
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,41 +1,61 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$assets/logo.png';
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
|
||||
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
|
||||
import { fontStack } from '$lib/themeFonts';
|
||||
import { readableTextColor, themeFontFaceCss, type ThemeV2Definition } from '$lib/themeV2';
|
||||
import { Brush, House, Music, Paintbrush, Play, Settings, User, Wifi } from '@lucide/svelte';
|
||||
import { scopeThemeCss } from '$lib/themeCssScope';
|
||||
import { fontStack, monoFontStack } from '$lib/themeFonts';
|
||||
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
|
||||
import {
|
||||
readableTextColor,
|
||||
themeFontFaceCss,
|
||||
type ThemeV2Definition,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2ScriptValue,
|
||||
} from '$lib/themeV2';
|
||||
|
||||
let {
|
||||
definition,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
selectedIds = [],
|
||||
selectedGroupId,
|
||||
interactive = false,
|
||||
scale = 1,
|
||||
variables = {},
|
||||
onselect,
|
||||
onselectmany,
|
||||
onselectgroup,
|
||||
onmove,
|
||||
onpatch,
|
||||
onpatchgroup,
|
||||
oncontext,
|
||||
oncontextgroup,
|
||||
}: {
|
||||
definition: ThemeV2Definition;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
selectedIds?: string[];
|
||||
selectedGroupId?: string;
|
||||
interactive?: boolean;
|
||||
scale?: number;
|
||||
variables?: Record<string, ThemeV2ScriptValue>;
|
||||
onselect?: (id: string | undefined, additive?: boolean) => void;
|
||||
onselectmany?: (ids: string[], additive?: boolean) => void;
|
||||
onselectgroup?: (id: string) => void;
|
||||
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
|
||||
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||
onpatchgroup?: (id: string, patch: Partial<ThemeV2Group>) => void;
|
||||
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||
oncontextgroup?: (id: string, event: MouseEvent) => void;
|
||||
} = $props();
|
||||
|
||||
const bodyFont = $derived(definition.typography?.body);
|
||||
const headingFont = $derived(definition.typography?.heading);
|
||||
const monoFont = $derived(definition.typography?.mono);
|
||||
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
|
||||
const accentText = $derived(readableTextColor(accent));
|
||||
const iconSet = $derived(definition.iconSet ?? DEFAULT_THEME_ICON_SET);
|
||||
const stageStyle = $derived(
|
||||
[
|
||||
`font-family:${fontStack(bodyFont)}`,
|
||||
@@ -46,17 +66,19 @@
|
||||
`--color-primary-foreground:${accentText}`,
|
||||
`--color-primary-500:${accent}`,
|
||||
`--color-ring:${accent}`,
|
||||
`--font-mono:${monoFontStack(monoFont)}`,
|
||||
].join(';')
|
||||
);
|
||||
const fontFaces = $derived(themeFontFaceCss(definition, assets, assetOverrides));
|
||||
const customCss = $derived(scopeThemeCss(definition.css, '.theme-stage'));
|
||||
const elements = $derived(definition.elements ?? []);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html `<style>${fontFaces}</style>`}
|
||||
{@html `<style>${fontFaces}\n${customCss}</style>`}
|
||||
|
||||
<div
|
||||
class="theme-stage relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
|
||||
class="theme-stage dark relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
|
||||
style={stageStyle}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -75,24 +97,24 @@
|
||||
3.2.7
|
||||
</span>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary/50 p-3 ring-1 ring-inset ring-white/15"
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<House class="size-5 text-theme-200" />
|
||||
<ThemeIcon name="home" set={iconSet} class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<Paintbrush class="size-5 text-theme-200" />
|
||||
<ThemeIcon name="themes" set={iconSet} class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<Settings class="size-5 text-theme-200" />
|
||||
<ThemeIcon name="settings" set={iconSet} class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-auto flex size-10 items-center justify-center overflow-hidden rounded-full bg-theme-900"
|
||||
>
|
||||
<User class="size-5 text-muted-foreground" />
|
||||
<ThemeIcon name="user" set={iconSet} class="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -139,15 +161,15 @@
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Music class="size-3.5 text-blue-400" />
|
||||
<ThemeIcon name="music" set={iconSet} class="size-3.5 text-blue-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">1.2k</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Brush class="size-3.5 text-amber-400" />
|
||||
<ThemeIcon name="brush" set={iconSet} class="size-3.5 text-amber-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">42</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Wifi class="size-3.5 text-emerald-400" />
|
||||
<ThemeIcon name="wifi" set={iconSet} class="size-3.5 text-emerald-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">19ms</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-white/10"></div>
|
||||
@@ -172,7 +194,11 @@
|
||||
<div
|
||||
class="relative z-10 flex size-9 items-center justify-center rounded-full bg-black/30"
|
||||
>
|
||||
<Play class="ml-0.5 size-4 fill-current text-primary-foreground" />
|
||||
<ThemeIcon
|
||||
name="launch"
|
||||
set={iconSet}
|
||||
class="ml-0.5 size-4 fill-current text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-52 pl-1 pr-6 text-center">
|
||||
@@ -185,15 +211,26 @@
|
||||
|
||||
<ThemeV2Elements
|
||||
{elements}
|
||||
groups={definition.groups}
|
||||
{assets}
|
||||
{assetOverrides}
|
||||
themeName={definition.name}
|
||||
themeAuthor={definition.author}
|
||||
themeVersion={definition.version}
|
||||
accentColor={definition.accentColor}
|
||||
{iconSet}
|
||||
{interactive}
|
||||
{selectedIds}
|
||||
{selectedGroupId}
|
||||
{scale}
|
||||
{variables}
|
||||
{onselect}
|
||||
{onselectmany}
|
||||
{onselectgroup}
|
||||
{onmove}
|
||||
{onpatch}
|
||||
{onpatchgroup}
|
||||
{oncontext}
|
||||
{oncontextgroup}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,364 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import type {
|
||||
ThemeV2TextDecoration,
|
||||
ThemeV2TextStyle,
|
||||
ThemeV2TextTransform,
|
||||
} from '$lib/themeV2';
|
||||
import { Bold, Italic, RotateCcw, Strikethrough, Underline } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
element,
|
||||
onchange,
|
||||
}: {
|
||||
element: ThemeV2TextStyle;
|
||||
onchange: (patch: Partial<ThemeV2TextStyle>) => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
const colorField = 'h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5';
|
||||
|
||||
const bold = $derived((element.fontWeight ?? 600) >= 700);
|
||||
const italic = $derived(element.fontStyle === 'italic');
|
||||
const underline = $derived((element.textDecoration ?? 'none').includes('underline'));
|
||||
const strike = $derived((element.textDecoration ?? 'none').includes('line-through'));
|
||||
const gradient = $derived(element.fillMode === 'gradient');
|
||||
|
||||
const transformOptions: { value: ThemeV2TextTransform; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'uppercase', label: 'UPPERCASE' },
|
||||
{ value: 'lowercase', label: 'lowercase' },
|
||||
{ value: 'capitalize', label: 'Capitalize' },
|
||||
];
|
||||
|
||||
const inputValue = (event: Event) => (event.currentTarget as HTMLInputElement).value;
|
||||
|
||||
function toggleDecoration(nextUnderline: boolean, nextStrike: boolean) {
|
||||
const parts: string[] = [];
|
||||
if (nextUnderline) parts.push('underline');
|
||||
if (nextStrike) parts.push('line-through');
|
||||
onchange({ textDecoration: (parts.join(' ') || 'none') as ThemeV2TextDecoration });
|
||||
}
|
||||
|
||||
function setFillMode(value: ThemeV2TextStyle['fillMode']) {
|
||||
const fillMode = value === 'gradient' ? 'gradient' : 'solid';
|
||||
onchange({
|
||||
fillMode,
|
||||
...(fillMode === 'gradient'
|
||||
? {
|
||||
gradientFrom: element.gradientFrom ?? '#ff006e',
|
||||
gradientTo: element.gradientTo ?? '#7c3aed',
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function resetTextStyle() {
|
||||
onchange({
|
||||
fontWeight: 600,
|
||||
fontStyle: 'normal',
|
||||
textDecoration: 'none',
|
||||
textTransform: 'none',
|
||||
fillMode: 'solid',
|
||||
gradientFrom: '#ff006e',
|
||||
gradientTo: '#7c3aed',
|
||||
gradientAngle: 90,
|
||||
outlineWidth: 0,
|
||||
outlineColor: '#000000',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
shadowColor: '#000000',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet textStyleActions()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
|
||||
title="Reset bold, italic, decoration, case, fill, outline and shadow"
|
||||
onclick={resetTextStyle}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="Text style"
|
||||
open={false}
|
||||
actions={textStyleActions}
|
||||
contentClass="space-y-2 pt-2.5"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-1">
|
||||
<Button
|
||||
variant={bold ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Bold"
|
||||
aria-label="Bold"
|
||||
aria-pressed={bold}
|
||||
onclick={() => onchange({ fontWeight: bold ? 400 : 700 })}
|
||||
>
|
||||
<Bold class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={italic ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Italic"
|
||||
aria-label="Italic"
|
||||
aria-pressed={italic}
|
||||
onclick={() => onchange({ fontStyle: italic ? 'normal' : 'italic' })}
|
||||
>
|
||||
<Italic class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={underline ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Underline"
|
||||
aria-label="Underline"
|
||||
aria-pressed={underline}
|
||||
onclick={() => toggleDecoration(!underline, strike)}
|
||||
>
|
||||
<Underline class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={strike ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Strikethrough"
|
||||
aria-label="Strikethrough"
|
||||
aria-pressed={strike}
|
||||
onclick={() => toggleDecoration(underline, !strike)}
|
||||
>
|
||||
<Strikethrough class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-text-transform" class={label}>Case</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.textTransform ?? 'none'}
|
||||
onValueChange={(value) => {
|
||||
if (value) onchange({ textTransform: value as ThemeV2TextTransform });
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-text-transform"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{transformOptions.find((option) => option.value === (element.textTransform ?? 'none'))
|
||||
?.label ?? 'None'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
{#each transformOptions as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-fill-mode" class={label}>Fill</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.fillMode ?? 'solid'}
|
||||
onValueChange={(value) => setFillMode(value as ThemeV2TextStyle['fillMode'])}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-fill-mode"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.fillMode === 'gradient' ? 'Gradient' : 'Solid'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="solid" label="Solid" />
|
||||
<Select.Item value="gradient" label="Gradient" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if gradient}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Gradient from</Label>
|
||||
<span class="font-mono text-[9px] text-theme-100"
|
||||
>{element.gradientFrom ?? '#ff006e'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
value={element.gradientFrom ?? '#ff006e'}
|
||||
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
value={element.gradientFrom ?? '#ff006e'}
|
||||
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Gradient to</Label>
|
||||
<span class="font-mono text-[9px] text-theme-100">{element.gradientTo ?? '#7c3aed'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
value={element.gradientTo ?? '#7c3aed'}
|
||||
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
value={element.gradientTo ?? '#7c3aed'}
|
||||
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-gradient-angle" class={label}>Gradient angle</Label>
|
||||
<Input
|
||||
id="element-gradient-angle"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="360"
|
||||
step="1"
|
||||
value={element.gradientAngle ?? 90}
|
||||
onchange={(event) =>
|
||||
onchange({
|
||||
gradientAngle: Math.min(360, Math.max(0, Number(inputValue(event)) || 0)),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Color</Label>
|
||||
<span class="font-mono text-[9px] text-theme-100">{element.color ?? '#ffffff'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
value={element.color ?? '#ffffff'}
|
||||
oninput={(event) => onchange({ color: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
value={element.color ?? '#ffffff'}
|
||||
oninput={(event) => onchange({ color: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label class={label}>Outline</Label>
|
||||
<div class="grid grid-cols-[72px_40px_1fr] gap-2">
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="20"
|
||||
step="1"
|
||||
aria-label="Outline width"
|
||||
title="Outline width in pixels; 0 disables it"
|
||||
value={element.outlineWidth ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({ outlineWidth: Math.min(20, Math.max(0, Number(inputValue(event)) || 0)) })}
|
||||
/>
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
aria-label="Outline color"
|
||||
value={element.outlineColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} min-w-0 font-mono"
|
||||
aria-label="Outline color hex"
|
||||
value={element.outlineColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label class={label}>Shadow</Label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
aria-label="Shadow blur"
|
||||
title="Shadow blur"
|
||||
value={element.shadowBlur ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({ shadowBlur: Math.min(40, Math.max(0, Number(inputValue(event)) || 0)) })}
|
||||
/>
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="-40"
|
||||
max="40"
|
||||
step="1"
|
||||
aria-label="Shadow offset X"
|
||||
title="Shadow offset X"
|
||||
value={element.shadowOffsetX ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({
|
||||
shadowOffsetX: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="-40"
|
||||
max="40"
|
||||
step="1"
|
||||
aria-label="Shadow offset Y"
|
||||
title="Shadow offset Y"
|
||||
value={element.shadowOffsetY ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({
|
||||
shadowOffsetY: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
aria-label="Shadow color"
|
||||
value={element.shadowColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
aria-label="Shadow color hex"
|
||||
value={element.shadowColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import type { ThemeV2Transform } from '$lib/themeV2';
|
||||
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte';
|
||||
|
||||
type TransformTarget = ThemeV2Transform & { offsetX?: number; offsetY?: number };
|
||||
type TransformPatch = Partial<TransformTarget>;
|
||||
|
||||
let {
|
||||
transform,
|
||||
idPrefix,
|
||||
includeTranslate = false,
|
||||
onchange,
|
||||
}: {
|
||||
transform: TransformTarget;
|
||||
idPrefix: string;
|
||||
includeTranslate?: boolean;
|
||||
onchange: (patch: TransformPatch) => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
|
||||
const scaleX = $derived(transform.scaleX ?? transform.scale ?? 1);
|
||||
const scaleY = $derived(transform.scaleY ?? transform.scale ?? 1);
|
||||
const isDefault = $derived(
|
||||
(transform.rotation ?? 0) === 0 &&
|
||||
scaleX === 1 &&
|
||||
scaleY === 1 &&
|
||||
(transform.skewX ?? 0) === 0 &&
|
||||
(transform.skewY ?? 0) === 0 &&
|
||||
!transform.flipX &&
|
||||
!transform.flipY &&
|
||||
(!includeTranslate || ((transform.offsetX ?? 0) === 0 && (transform.offsetY ?? 0) === 0))
|
||||
);
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
function numberValue(value: string): number | undefined {
|
||||
if (value.trim() === '') return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function setScale(axis: 'scaleX' | 'scaleY', value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
const scale = clamp(parsed / 100, 0.05, 10);
|
||||
onchange(axis === 'scaleX' ? { scaleX: scale } : { scaleY: scale });
|
||||
}
|
||||
|
||||
function setSkew(axis: 'skewX' | 'skewY', value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
const skew = clamp(parsed, -89, 89);
|
||||
onchange(axis === 'skewX' ? { skewX: skew } : { skewY: skew });
|
||||
}
|
||||
|
||||
function setRotation(value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
onchange({ rotation: clamp(parsed, -360, 360) });
|
||||
}
|
||||
|
||||
function rotateBy(delta: number) {
|
||||
const next = (transform.rotation ?? 0) + delta;
|
||||
onchange({ rotation: ((((next + 180) % 360) + 360) % 360) - 180 });
|
||||
}
|
||||
|
||||
function setOffset(axis: 'offsetX' | 'offsetY', value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
const offset = clamp(parsed, -2000, 2000);
|
||||
onchange(axis === 'offsetX' ? { offsetX: offset } : { offsetY: offset });
|
||||
}
|
||||
|
||||
function resetTransform() {
|
||||
onchange({
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
...(includeTranslate ? { offsetX: 0, offsetY: 0 } : {}),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet transformActions()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
|
||||
title={includeTranslate
|
||||
? 'Reset translation, rotation, scale, skew and flips'
|
||||
: 'Reset rotation, scale, skew and flips'}
|
||||
disabled={isDefault}
|
||||
onclick={resetTransform}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="Transform"
|
||||
open={false}
|
||||
actions={transformActions}
|
||||
contentClass="space-y-2 pt-2.5"
|
||||
>
|
||||
{#if includeTranslate}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-translate-x" class={label}>Translate X</Label>
|
||||
<Input
|
||||
id="{idPrefix}-translate-x"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-2000"
|
||||
max="2000"
|
||||
value={transform.offsetX ?? 0}
|
||||
onchange={(event) => setOffset('offsetX', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-translate-y" class={label}>Translate Y</Label>
|
||||
<Input
|
||||
id="{idPrefix}-translate-y"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-2000"
|
||||
max="2000"
|
||||
value={transform.offsetY ?? 0}
|
||||
onchange={(event) => setOffset('offsetY', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-scale-x" class={label}>Scale X %</Label>
|
||||
<Input
|
||||
id="{idPrefix}-scale-x"
|
||||
class={field}
|
||||
type="number"
|
||||
min="5"
|
||||
max="1000"
|
||||
step="5"
|
||||
value={Math.round(scaleX * 100)}
|
||||
onchange={(event) => setScale('scaleX', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-scale-y" class={label}>Scale Y %</Label>
|
||||
<Input
|
||||
id="{idPrefix}-scale-y"
|
||||
class={field}
|
||||
type="number"
|
||||
min="5"
|
||||
max="1000"
|
||||
step="5"
|
||||
value={Math.round(scaleY * 100)}
|
||||
onchange={(event) => setScale('scaleY', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-skew-x" class={label}>Skew X °</Label>
|
||||
<Input
|
||||
id="{idPrefix}-skew-x"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-89"
|
||||
max="89"
|
||||
step="1"
|
||||
value={transform.skewX ?? 0}
|
||||
onchange={(event) => setSkew('skewX', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-skew-y" class={label}>Skew Y °</Label>
|
||||
<Input
|
||||
id="{idPrefix}-skew-y"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-89"
|
||||
max="89"
|
||||
step="1"
|
||||
value={transform.skewY ?? 0}
|
||||
onchange={(event) => setSkew('skewY', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<Label for="{idPrefix}-rotation" class={label}>Rotation °</Label>
|
||||
<Input
|
||||
id="{idPrefix}-rotation"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-360"
|
||||
max="360"
|
||||
step="1"
|
||||
value={transform.rotation ?? 0}
|
||||
onchange={(event) => setRotation(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
variant={transform.flipX ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Flip horizontally"
|
||||
aria-label="Flip horizontally"
|
||||
aria-pressed={transform.flipX === true}
|
||||
onclick={() => onchange({ flipX: !transform.flipX })}
|
||||
>
|
||||
<FlipHorizontal2 class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={transform.flipY ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Flip vertically"
|
||||
aria-label="Flip vertically"
|
||||
aria-pressed={transform.flipY === true}
|
||||
onclick={() => onchange({ flipY: !transform.flipY })}
|
||||
>
|
||||
<FlipVertical2 class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Rotate left 90°"
|
||||
aria-label="Rotate left 90 degrees"
|
||||
onclick={() => rotateBy(-90)}
|
||||
>
|
||||
<RotateCcw class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Rotate right 90°"
|
||||
aria-label="Rotate right 90 degrees"
|
||||
onclick={() => rotateBy(90)}
|
||||
>
|
||||
<RotateCw class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import type { ThemeV2ScriptValue } from '$lib/themeV2';
|
||||
import { RotateCcw } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
variables,
|
||||
defaults,
|
||||
onchange,
|
||||
}: {
|
||||
variables: string[];
|
||||
defaults: Record<string, ThemeV2ScriptValue>;
|
||||
onchange: (name: string, value: ThemeV2ScriptValue | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
const hasDefault = (name: string) => Object.prototype.hasOwnProperty.call(defaults, name);
|
||||
|
||||
const valueLabel = (value: ThemeV2ScriptValue) =>
|
||||
typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'text';
|
||||
|
||||
const displayValue = (name: string) => {
|
||||
if (!hasDefault(name)) return '';
|
||||
const value = defaults[name];
|
||||
return typeof value === 'string' ? value : String(value);
|
||||
};
|
||||
|
||||
const parseValue = (raw: string): ThemeV2ScriptValue | undefined => {
|
||||
if (!raw.trim()) return undefined;
|
||||
if (raw.trim() === 'true') return true;
|
||||
if (raw.trim() === 'false') return false;
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(raw.trim())) {
|
||||
const number = Number(raw.trim());
|
||||
if (Number.isFinite(number)) return number;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
</script>
|
||||
|
||||
<ThemeSection title="Variables" badge={variables.length} contentClass="space-y-2 pt-2">
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||
Starting values for
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{name}'}</code>
|
||||
placeholders and logic variables. Leave a field empty to start at
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">0</code>.
|
||||
</p>
|
||||
|
||||
{#if variables.length === 0}
|
||||
<p
|
||||
class="rounded-md border border-dashed border-theme-800 p-2.5 text-[10px] leading-relaxed text-muted-foreground"
|
||||
>
|
||||
Add a
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{placeholder}'}</code>
|
||||
to a text layer or use a variable in a rule to see it here.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-1.5">
|
||||
{#each variables as name (name)}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1.5"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-mono text-[10px] text-theme-100" title={`{${name}}`}>
|
||||
{name}
|
||||
</div>
|
||||
<div class="text-[8px] leading-tight text-muted-foreground">
|
||||
{hasDefault(name) ? valueLabel(defaults[name]) : 'no default · starts at 0'}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
class="h-7 w-24 shrink-0 border-theme-800 bg-theme-950 px-2 font-mono text-[10px] text-theme-50"
|
||||
value={displayValue(name)}
|
||||
placeholder="0"
|
||||
spellcheck="false"
|
||||
aria-label={`Default value for ${name}`}
|
||||
onchange={(event) => onchange(name, parseValue(event.currentTarget.value))}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-theme-100"
|
||||
title={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
|
||||
aria-label={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
|
||||
disabled={!hasDefault(name)}
|
||||
onclick={() => onchange(name, undefined)}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</ThemeSection>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Ellipsis from '@lucide/svelte/icons/ellipsis';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
|
||||
|
||||
const prop: {
|
||||
downloading: boolean;
|
||||
@@ -8,6 +8,7 @@
|
||||
text: string;
|
||||
subtext?: string;
|
||||
disabled?: boolean;
|
||||
iconSet?: ThemeIconSet;
|
||||
onClick?: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -66,14 +67,22 @@
|
||||
<div class="size-9 rounded-full bg-black/30 flex items-center justify-center relative z-10">
|
||||
{#if prop.downloading}
|
||||
{#if prop.progress === -1}
|
||||
<Ellipsis class="size-4 text-primary-foreground fill-current animate-pulse" />
|
||||
<ThemeIcon
|
||||
name="ellipsis"
|
||||
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
||||
class="size-4 text-primary-foreground fill-current animate-pulse"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-[10px] font-bold text-primary-foreground">
|
||||
{prop.progress.toFixed()}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<Play class="size-4 text-primary-foreground fill-current ml-0.5" />
|
||||
<ThemeIcon
|
||||
name="launch"
|
||||
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
||||
class="size-4 text-primary-foreground fill-current ml-0.5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<script lang="ts">
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import { config } from '$lib/config';
|
||||
import { custom_theme_audio_playing, custom_theme_volume, platform } from '$lib/global';
|
||||
import {
|
||||
active_custom_theme,
|
||||
custom_theme_audio_playing,
|
||||
custom_theme_volume,
|
||||
platform,
|
||||
} from '$lib/global';
|
||||
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
import { Volume, Volume1, VolumeOff } from '@lucide/svelte';
|
||||
import Minimize from '@lucide/svelte/icons/minus';
|
||||
import Volume2 from '@lucide/svelte/icons/volume-2';
|
||||
import Close from '@lucide/svelte/icons/x';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const iconSet = $derived($active_custom_theme?.definition?.iconSet ?? DEFAULT_THEME_ICON_SET);
|
||||
|
||||
onMount(() => {
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
@@ -33,13 +40,13 @@
|
||||
></div>
|
||||
|
||||
{#if $custom_theme_volume > 0.7}
|
||||
<Volume2 class="z-10 pointer-events-none" size={14} />
|
||||
<ThemeIcon name="volume" set={iconSet} class="z-10 pointer-events-none" size={14} />
|
||||
{:else if $custom_theme_volume >= 0.15}
|
||||
<Volume1 class="z-10 pointer-events-none" size={14} />
|
||||
<ThemeIcon name="volume-low" set={iconSet} class="z-10 pointer-events-none" size={14} />
|
||||
{:else if $custom_theme_volume > 0}
|
||||
<Volume class="z-10 pointer-events-none" size={14} />
|
||||
<ThemeIcon name="volume-low" set={iconSet} class="z-10 pointer-events-none" size={14} />
|
||||
{:else}
|
||||
<VolumeOff class="z-10 pointer-events-none" size={14} />
|
||||
<ThemeIcon name="volume-off" set={iconSet} class="z-10 pointer-events-none" size={14} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +69,7 @@
|
||||
max="1"
|
||||
step="0.01"
|
||||
bind:value={$custom_theme_volume}
|
||||
on:input={() => {
|
||||
oninput={() => {
|
||||
config.value<number>('volume').set($custom_theme_volume);
|
||||
}}
|
||||
class="volume-slider"
|
||||
|
||||
Reference in new issue
Block a user