feat: polish theme tooling, diagnostics and launcher workflows
Expand the theme editor with multi-select, snapping, context menus, draft recovery and export validation; add theme backups and trust prompts. Add in-app diagnostics and rotating log bundle viewing, hardware-accelerated theme video playback on macOS, and refresh the settings, setup wizard and theme gallery layouts.
This commit is contained in:
1 parent
87202de656
commit
c02b26e8ae
41 files changed
+5574
-865
No files matched your search
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { launcherStream } from '$lib/global';
|
||||
import { getReleaseStream, getVersion, isOsuCorrupted } from '$lib/osuUtil';
|
||||
import { osuInstallationPath, patch } from '$lib/userSettings';
|
||||
import { formatBytes } from '$lib/utils';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ClipboardCopy,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from '@lucide/svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { sileo } from 'sileo';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type InstallationDiagnostics = {
|
||||
platform: string;
|
||||
osVersion?: string;
|
||||
osArch: string;
|
||||
launcherVersion: string;
|
||||
osuFolder: string;
|
||||
resolvedFolder?: string;
|
||||
osuWritable?: boolean;
|
||||
patcherPath?: string;
|
||||
patcherInstalled: boolean;
|
||||
patcherFileCount: number;
|
||||
patcherTotalSize: number;
|
||||
hostNet8: boolean;
|
||||
osuwinello: boolean;
|
||||
wmctrl: boolean;
|
||||
winePrefix?: string;
|
||||
wineBinary?: string;
|
||||
wineAppBundle?: string;
|
||||
wineVersion?: string;
|
||||
winePrefixArch?: string;
|
||||
wineNet8?: boolean;
|
||||
wineError?: string;
|
||||
ffmpegStatus?: string;
|
||||
logsDirectory: string;
|
||||
};
|
||||
|
||||
type Check = {
|
||||
label: string;
|
||||
detail: string;
|
||||
status: 'ok' | 'warning' | 'error' | 'info';
|
||||
};
|
||||
|
||||
let { onRepair }: { onRepair?: () => void } = $props();
|
||||
|
||||
let checking = $state(true);
|
||||
let checks = $state<Check[]>([]);
|
||||
let lastChecked = $state<Date | undefined>(undefined);
|
||||
let platformName = $state('');
|
||||
let launcherVersionName = $state('');
|
||||
let osVersion = $state('');
|
||||
let osArch = $state('');
|
||||
let logsDirectory = $state('');
|
||||
|
||||
const icons = {
|
||||
ok: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const platformLabel = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'macos':
|
||||
return 'macOS';
|
||||
case 'windows':
|
||||
return 'Windows';
|
||||
case 'linux':
|
||||
return 'Linux';
|
||||
default:
|
||||
return platform || 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
async function runChecks() {
|
||||
checking = true;
|
||||
checks = [];
|
||||
|
||||
try {
|
||||
const folder = $osuInstallationPath;
|
||||
const diagnostics = await invoke<InstallationDiagnostics>('installation_diagnostics', {
|
||||
folder,
|
||||
});
|
||||
platformName = diagnostics.platform;
|
||||
launcherVersionName = diagnostics.launcherVersion;
|
||||
osVersion = diagnostics.osVersion ?? '';
|
||||
osArch = diagnostics.osArch;
|
||||
logsDirectory = diagnostics.logsDirectory;
|
||||
const resolved = diagnostics.resolvedFolder;
|
||||
// Only the experimental patcher build targets .NET 8.
|
||||
const needsNet8 = $patch && $launcherStream === 'experimental';
|
||||
const result: Check[] = [];
|
||||
|
||||
result.push({
|
||||
label: 'Launcher',
|
||||
detail: `${diagnostics.launcherVersion || 'unknown version'} · ${
|
||||
diagnostics.osVersion || platformLabel(diagnostics.platform)
|
||||
}${diagnostics.osArch ? ` (${diagnostics.osArch})` : ''}`,
|
||||
status: 'ok',
|
||||
});
|
||||
|
||||
if (!folder.trim()) {
|
||||
result.push({
|
||||
label: 'osu! installation',
|
||||
detail: 'No installation path is configured. Run the setup wizard to pick osu!.',
|
||||
status: 'error',
|
||||
});
|
||||
} else if (!resolved) {
|
||||
result.push({
|
||||
label: 'osu! installation',
|
||||
detail: `The configured path could not be resolved: ${folder}`,
|
||||
status: 'error',
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
label: 'osu! installation',
|
||||
detail: resolved,
|
||||
status: 'ok',
|
||||
});
|
||||
|
||||
try {
|
||||
const [version, stream] = await Promise.all([
|
||||
getVersion(resolved),
|
||||
getReleaseStream(resolved),
|
||||
]);
|
||||
result.push({
|
||||
label: 'osu! version',
|
||||
detail: `${stream ?? 'unknown stream'} · ${version || 'version unavailable'}`,
|
||||
status: version ? 'ok' : 'warning',
|
||||
});
|
||||
} catch (error) {
|
||||
result.push({
|
||||
label: 'osu! version',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const corrupted = await isOsuCorrupted(resolved);
|
||||
result.push({
|
||||
label: 'Game files',
|
||||
detail: corrupted
|
||||
? 'osu!.exe or a required file looks corrupted. Re-run the updater or setup.'
|
||||
: 'The osu! executable passed the integrity check.',
|
||||
status: corrupted ? 'error' : 'ok',
|
||||
});
|
||||
} catch (error) {
|
||||
result.push({
|
||||
label: 'Game files',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof diagnostics.osuWritable === 'boolean') {
|
||||
result.push({
|
||||
label: 'osu! folder access',
|
||||
detail: diagnostics.osuWritable
|
||||
? 'The launcher can write to this folder, so updates and patcher downloads can be installed.'
|
||||
: 'The launcher cannot write to this folder. Updates and patcher downloads will fail; check folder permissions.',
|
||||
status: diagnostics.osuWritable ? 'ok' : 'error',
|
||||
});
|
||||
}
|
||||
|
||||
const selectedStream = $launcherStream;
|
||||
const effectiveStream = diagnostics.platform === 'windows' ? selectedStream : 'stable';
|
||||
result.push({
|
||||
label: 'Patcher stream',
|
||||
detail:
|
||||
diagnostics.platform === 'windows'
|
||||
? `${selectedStream} selected`
|
||||
: `${effectiveStream} (selected: ${selectedStream}; macOS/Linux always use stable)`,
|
||||
status: 'ok',
|
||||
});
|
||||
|
||||
result.push({
|
||||
label: 'Patcher',
|
||||
detail: diagnostics.patcherInstalled
|
||||
? `${diagnostics.patcherFileCount} file${
|
||||
diagnostics.patcherFileCount === 1 ? '' : 's'
|
||||
}, ${formatBytes(diagnostics.patcherTotalSize)} · ${diagnostics.patcherPath}`
|
||||
: 'Patcher files are not installed yet. They will be downloaded automatically the next time you launch osu!.',
|
||||
status: diagnostics.patcherInstalled ? 'ok' : $patch ? 'warning' : 'info',
|
||||
});
|
||||
|
||||
result.push({
|
||||
label: 'Logs',
|
||||
detail: diagnostics.logsDirectory || 'The logs folder is unavailable.',
|
||||
status: diagnostics.logsDirectory ? 'ok' : 'warning',
|
||||
});
|
||||
|
||||
if (diagnostics.platform === 'macos') {
|
||||
if (diagnostics.wineError) {
|
||||
result.push({
|
||||
label: 'Wine wrapper',
|
||||
detail: diagnostics.wineError,
|
||||
status: 'error',
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
label: 'Wine prefix',
|
||||
detail: diagnostics.winePrefix || 'Wine prefix not detected.',
|
||||
status: diagnostics.winePrefix ? 'ok' : 'error',
|
||||
});
|
||||
result.push({
|
||||
label: 'Wine runtime',
|
||||
detail: diagnostics.wineBinary || 'No Wine loader found inside the wrapper.',
|
||||
status: diagnostics.wineBinary ? 'ok' : 'error',
|
||||
});
|
||||
result.push({
|
||||
label: 'Wine version',
|
||||
detail: diagnostics.wineVersion || 'Could not determine the Wine version.',
|
||||
status: diagnostics.wineVersion ? 'ok' : 'warning',
|
||||
});
|
||||
if (diagnostics.winePrefixArch) {
|
||||
result.push({
|
||||
label: 'Wine prefix architecture',
|
||||
detail:
|
||||
diagnostics.winePrefixArch === 'win64'
|
||||
? '64-bit (win64)'
|
||||
: diagnostics.winePrefixArch === 'win32'
|
||||
? '32-bit (win32)'
|
||||
: diagnostics.winePrefixArch,
|
||||
status: 'ok',
|
||||
});
|
||||
}
|
||||
if (diagnostics.wineAppBundle) {
|
||||
result.push({
|
||||
label: 'App wrapper',
|
||||
detail: diagnostics.wineAppBundle,
|
||||
status: 'ok',
|
||||
});
|
||||
}
|
||||
result.push({
|
||||
label: '.NET 8 in prefix (experimental)',
|
||||
detail:
|
||||
diagnostics.wineNet8 === true
|
||||
? 'The Microsoft.WindowsDesktop.App 8 runtime was found.'
|
||||
: 'The Windows .NET 8 Desktop Runtime was not found in this prefix. Only the experimental patcher stream needs it.',
|
||||
status: diagnostics.wineNet8 === true ? 'ok' : needsNet8 ? 'error' : 'warning',
|
||||
});
|
||||
result.push({
|
||||
label: 'FFmpeg (theme video)',
|
||||
detail: diagnostics.ffmpegStatus
|
||||
? diagnostics.ffmpegStatus
|
||||
: 'FFmpeg was not found. Large theme videos will stay on WebKit’s software decoder and may stutter.',
|
||||
status: diagnostics.ffmpegStatus ? 'ok' : 'warning',
|
||||
});
|
||||
}
|
||||
} else if (diagnostics.platform === 'linux') {
|
||||
result.push({
|
||||
label: 'osu-winello',
|
||||
detail: diagnostics.osuwinello
|
||||
? 'osu-winello was found.'
|
||||
: 'osu-winello was not found in PATH. Launching osu! may fail.',
|
||||
status: diagnostics.osuwinello ? 'ok' : 'error',
|
||||
});
|
||||
result.push({
|
||||
label: 'wmctrl',
|
||||
detail: diagnostics.wmctrl
|
||||
? 'wmctrl is available for window detection.'
|
||||
: 'wmctrl is missing. The patcher may not detect the osu! window.',
|
||||
status: diagnostics.wmctrl ? 'ok' : $patch ? 'warning' : 'info',
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
label: '.NET 8 Desktop Runtime (experimental)',
|
||||
detail: diagnostics.hostNet8
|
||||
? 'Microsoft.WindowsDesktop.App 8 was found.'
|
||||
: 'The .NET 8 Desktop Runtime was not found. Only the experimental patcher stream needs it.',
|
||||
status: diagnostics.hostNet8 ? 'ok' : needsNet8 ? 'error' : 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
checks = result;
|
||||
lastChecked = new Date();
|
||||
} catch (error) {
|
||||
checks = [
|
||||
{
|
||||
label: 'Diagnostics',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
status: 'error',
|
||||
},
|
||||
];
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyReport() {
|
||||
const body = checks
|
||||
.map((check) => `[${check.status.toUpperCase()}] ${check.label}: ${check.detail}`)
|
||||
.join('\n');
|
||||
const report = [
|
||||
'EZPPLauncher diagnostics',
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Launcher: ${launcherVersionName || 'unknown'}`,
|
||||
`Platform: ${osVersion || platformLabel(platformName)}${osArch ? ` (${osArch})` : ''}`,
|
||||
`Logs: ${logsDirectory || 'unknown'}`,
|
||||
'',
|
||||
body,
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(report);
|
||||
sileo.success({
|
||||
title: 'Diagnostics copied',
|
||||
description: 'Paste it into your support report.',
|
||||
});
|
||||
} catch {
|
||||
sileo.error({ title: 'Could not copy diagnostics' });
|
||||
}
|
||||
}
|
||||
|
||||
onMount(runChecks);
|
||||
</script>
|
||||
|
||||
<div class="divide-y divide-theme-800/50 border-t border-theme-800/50">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 py-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium">System checks</p>
|
||||
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
Inspect the selected osu! install, folder permissions, Wine wrapper, patcher, FFmpeg and
|
||||
runtime dependencies.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
{#if lastChecked}
|
||||
<span class="mr-1 text-[10px] text-muted-foreground">
|
||||
{lastChecked.toLocaleTimeString()}
|
||||
</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-theme-800 bg-theme-950"
|
||||
onclick={copyReport}
|
||||
>
|
||||
<ClipboardCopy class="size-4" />
|
||||
Copy report
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-theme-800 bg-theme-950"
|
||||
onclick={runChecks}
|
||||
disabled={checking}
|
||||
>
|
||||
<RefreshCw class={checking ? 'size-4 animate-spin' : 'size-4'} />
|
||||
Refresh
|
||||
</Button>
|
||||
{#if onRepair}
|
||||
<Button variant="secondary" size="sm" onclick={onRepair}>
|
||||
<Wrench class="size-4" />
|
||||
Repair setup
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 py-3">
|
||||
{#if checking}
|
||||
<div class="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<LoaderCircle class="size-4 animate-spin" />
|
||||
Checking your installation…
|
||||
</div>
|
||||
{:else}
|
||||
{#each checks as check (check.label)}
|
||||
{@const Icon = icons[check.status]}
|
||||
<div
|
||||
class="flex items-start gap-2.5 rounded-lg border border-theme-800/70 bg-theme-950/60 px-3 py-2"
|
||||
>
|
||||
<Icon
|
||||
class="mt-0.5 size-4 shrink-0 {check.status === 'ok'
|
||||
? 'text-emerald-400'
|
||||
: check.status === 'warning'
|
||||
? 'text-amber-300'
|
||||
: check.status === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'}"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs font-medium text-theme-100">{check.label}</p>
|
||||
<Badge
|
||||
variant={check.status === 'error'
|
||||
? 'destructive'
|
||||
: check.status === 'warning'
|
||||
? 'secondary'
|
||||
: 'outline'}
|
||||
class="px-1.5 py-0 text-[9px] capitalize"
|
||||
>
|
||||
{check.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-0.5 break-words text-[10px] leading-relaxed text-muted-foreground">
|
||||
{check.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
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 { openLogsFolder } from '$lib/osuUtil';
|
||||
import { formatBytes } from '$lib/utils';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
X,
|
||||
} from '@lucide/svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { sileo } from 'sileo';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type LogBundleInfo = {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
modified: number;
|
||||
};
|
||||
|
||||
type LogBundleEntry = {
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
let { onclose }: { onclose: () => void } = $props();
|
||||
|
||||
let bundles = $state<LogBundleInfo[]>([]);
|
||||
let selectedPath = $state<string | undefined>(undefined);
|
||||
let entries = $state<LogBundleEntry[]>([]);
|
||||
let selectedLog = $state('launcher.log');
|
||||
let query = $state('');
|
||||
let errorsOnly = $state(false);
|
||||
let loadingBundles = $state(true);
|
||||
let loadingEntries = $state(false);
|
||||
let errorMessage = $state('');
|
||||
|
||||
const selectedBundle = $derived(bundles.find((bundle) => bundle.path === selectedPath));
|
||||
const activeEntry = $derived(entries.find((entry) => entry.name === selectedLog));
|
||||
const allLines = $derived(activeEntry?.content.split(/\r?\n/) ?? []);
|
||||
const filteredLines = $derived.by(() => {
|
||||
const text = query.trim().toLowerCase();
|
||||
return allLines.filter((line) => {
|
||||
if (errorsOnly && !/(error|warn|panic|fatal|failed)/i.test(line)) return false;
|
||||
return !text || line.toLowerCase().includes(text);
|
||||
});
|
||||
});
|
||||
const visibleLines = $derived(filteredLines.slice(0, 5000));
|
||||
|
||||
async function loadBundles() {
|
||||
loadingBundles = true;
|
||||
errorMessage = '';
|
||||
try {
|
||||
bundles = await invoke<LogBundleInfo[]>('list_log_bundles');
|
||||
if (bundles.length === 0) {
|
||||
selectedPath = undefined;
|
||||
entries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const stillThere = bundles.some((bundle) => bundle.path === selectedPath);
|
||||
const next = stillThere ? selectedPath : bundles[0].path;
|
||||
selectedPath = next;
|
||||
await loadBundle(next!);
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
loadingBundles = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBundle(path: string) {
|
||||
selectedPath = path;
|
||||
loadingEntries = true;
|
||||
errorMessage = '';
|
||||
try {
|
||||
entries = await invoke<LogBundleEntry[]>('read_log_bundle', { path });
|
||||
if (!entries.some((entry) => entry.name === selectedLog)) {
|
||||
selectedLog = entries[0]?.name ?? 'launcher.log';
|
||||
}
|
||||
} catch (error) {
|
||||
entries = [];
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
loadingEntries = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await loadBundles();
|
||||
}
|
||||
|
||||
async function copyVisible() {
|
||||
const text = filteredLines.join('\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
sileo.success({
|
||||
title: 'Log lines copied',
|
||||
description: `${filteredLines.length} lines from ${selectedLog}.`,
|
||||
});
|
||||
} catch {
|
||||
sileo.error({ title: 'Could not copy the log' });
|
||||
}
|
||||
}
|
||||
|
||||
async function openFolder() {
|
||||
try {
|
||||
await openLogsFolder();
|
||||
} catch (error) {
|
||||
sileo.error({
|
||||
title: 'Could not open the logs folder',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function lineClass(line: string) {
|
||||
if (/(error|panic|fatal|failed)/i.test(line)) return 'text-red-300';
|
||||
if (/(warn|deprecated|missing)/i.test(line)) return 'text-amber-200';
|
||||
if (/(info|started|loaded|saved)/i.test(line)) return 'text-theme-200';
|
||||
return 'text-theme-300';
|
||||
}
|
||||
|
||||
onMount(loadBundles);
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') onclose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-5 backdrop-blur-sm"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
class="flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-theme-700 bg-theme-950 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="log-viewer-title"
|
||||
>
|
||||
<header class="flex shrink-0 items-center justify-between gap-3 border-b border-theme-800 p-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="flex size-8 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
||||
<Terminal class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 id="log-viewer-title" class="text-sm font-semibold">Log bundles</h2>
|
||||
<p class="truncate text-[10px] text-muted-foreground">
|
||||
Launcher, osu! and patcher output, grouped by session
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-theme-800 bg-theme-950"
|
||||
onclick={openFolder}
|
||||
>
|
||||
<FolderOpen class="size-3.5" />
|
||||
Folder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-theme-800 bg-theme-950"
|
||||
onclick={refresh}
|
||||
disabled={loadingBundles}
|
||||
>
|
||||
<RefreshCw class={loadingBundles ? 'size-3.5 animate-spin' : 'size-3.5'} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="size-8" title="Close logs" onclick={onclose}>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-cols-[230px_minmax(0,1fr)]">
|
||||
<aside class="flex min-h-0 flex-col border-r border-theme-800 bg-black/20">
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between border-b border-theme-800 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<span>Bundles</span>
|
||||
<span>{bundles.length}</span>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-1.5">
|
||||
{#if loadingBundles}
|
||||
<div class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<LoaderCircle class="size-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
{:else if bundles.length === 0}
|
||||
<div class="p-3 text-[11px] leading-relaxed text-muted-foreground">
|
||||
No bundles yet. Logs are zipped on the next launcher restart or when a log reaches 5
|
||||
MiB.
|
||||
</div>
|
||||
{:else}
|
||||
{#each bundles as bundle (bundle.path)}
|
||||
<button
|
||||
type="button"
|
||||
class="mb-1 w-full rounded-md border px-2 py-2 text-left transition {bundle.path ===
|
||||
selectedPath
|
||||
? 'border-primary/40 bg-primary/10'
|
||||
: 'border-transparent hover:border-theme-800 hover:bg-theme-900/60'}"
|
||||
onclick={() => loadBundle(bundle.path)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<FileText class="size-3.5 shrink-0 text-primary" />
|
||||
<span class="truncate text-[11px] font-medium text-theme-100">
|
||||
{bundle.name}
|
||||
</span>
|
||||
</span>
|
||||
<span class="mt-1 block pl-5 text-[9px] text-muted-foreground">
|
||||
{new Date(bundle.modified).toLocaleString()} · {formatBytes(bundle.size)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex min-h-0 min-w-0 flex-col">
|
||||
{#if errorMessage}
|
||||
<div
|
||||
class="flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive"
|
||||
>
|
||||
<AlertTriangle class="size-4" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-theme-800 p-2">
|
||||
{#if entries.length > 0}
|
||||
{#each entries as entry (entry.name)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-[10px] {selectedLog === entry.name
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={() => (selectedLog = entry.name)}
|
||||
>
|
||||
{entry.name}
|
||||
</Button>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="relative ml-auto w-52">
|
||||
<Search
|
||||
class="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
class="h-7 border-theme-800 bg-theme-950 pl-7 text-[10px]"
|
||||
placeholder="Filter lines…"
|
||||
bind:value={query}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1"
|
||||
>
|
||||
<Checkbox id="log-errors-only" bind:checked={errorsOnly} />
|
||||
<label for="log-errors-only" class="text-[10px] text-muted-foreground">
|
||||
Errors only
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 border-theme-800 bg-theme-950 text-[10px]"
|
||||
onclick={copyVisible}
|
||||
disabled={filteredLines.length === 0}
|
||||
>
|
||||
<Copy class="size-3" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto bg-black/25">
|
||||
{#if loadingEntries}
|
||||
<div
|
||||
class="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<LoaderCircle class="size-4 animate-spin" />
|
||||
Reading bundle…
|
||||
</div>
|
||||
{:else if !selectedBundle}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
|
||||
>
|
||||
<Terminal class="size-8 opacity-40" />
|
||||
<p class="text-xs">Choose a log bundle to inspect it.</p>
|
||||
</div>
|
||||
{:else if filteredLines.length === 0}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
|
||||
>
|
||||
<Search class="size-6 opacity-40" />
|
||||
<p class="text-xs">No lines match this filter.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-3 font-mono text-[10px] leading-relaxed">
|
||||
{#each visibleLines as line, index (index)}
|
||||
<div class="whitespace-pre-wrap break-words {lineClass(line)}">{line || ' '}</div>
|
||||
{/each}
|
||||
{#if filteredLines.length > visibleLines.length}
|
||||
<p class="mt-2 text-amber-200">
|
||||
Showing the first {visibleLines.length} matching lines. Narrow the filter to see more.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="flex shrink-0 items-center justify-between border-t border-theme-800 px-3 py-1.5 text-[9px] text-muted-foreground"
|
||||
>
|
||||
<span>{filteredLines.length} matching lines</span>
|
||||
<span>
|
||||
{selectedBundle
|
||||
? `${selectedBundle.name} · ${formatBytes(selectedBundle.size)}`
|
||||
: 'No bundle selected'}
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -39,6 +39,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="element-name" class={label}>Layer name</Label>
|
||||
<Input
|
||||
id="element-name"
|
||||
class={field}
|
||||
value={element.name ?? ''}
|
||||
placeholder="Optional label, for example Logo"
|
||||
oninput={(event) => (element.name = (event.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-x" class={label}>X</Label>
|
||||
@@ -98,6 +109,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 border-t border-theme-800 pt-3">
|
||||
<div class="flex items-center gap-2 rounded-md border border-theme-800 bg-black/20 px-2 py-1.5">
|
||||
<Checkbox
|
||||
id="element-hidden"
|
||||
checked={element.hidden === true}
|
||||
onCheckedChange={(checked) => (element.hidden = checked === true)}
|
||||
/>
|
||||
<Label for="element-hidden" class="text-[10px] font-normal text-muted-foreground">
|
||||
Hidden
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded-md border border-theme-800 bg-black/20 px-2 py-1.5">
|
||||
<Checkbox
|
||||
id="element-locked"
|
||||
checked={element.locked === true}
|
||||
onCheckedChange={(checked) => (element.locked = checked === true)}
|
||||
/>
|
||||
<Label for="element-locked" class="text-[10px] font-normal text-muted-foreground">
|
||||
Locked
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if element.type === 'text'}
|
||||
<div class="space-y-3 border-t border-theme-800 pt-3">
|
||||
<div class="space-y-1">
|
||||
|
||||
@@ -10,20 +10,26 @@
|
||||
definition,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
selectedId = undefined,
|
||||
selectedIds = [],
|
||||
interactive = false,
|
||||
scale = 1,
|
||||
onselect,
|
||||
onselectmany,
|
||||
onmove,
|
||||
onpatch,
|
||||
oncontext,
|
||||
}: {
|
||||
definition: ThemeV2Definition;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
selectedId?: string;
|
||||
selectedIds?: string[];
|
||||
interactive?: boolean;
|
||||
scale?: number;
|
||||
onselect?: (id: string | undefined) => void;
|
||||
onselect?: (id: string | undefined, additive?: boolean) => void;
|
||||
onselectmany?: (ids: string[], additive?: boolean) => void;
|
||||
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
|
||||
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||
} = $props();
|
||||
|
||||
const bodyFont = $derived(definition.typography?.body);
|
||||
@@ -53,9 +59,6 @@
|
||||
class="theme-stage relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
|
||||
style={stageStyle}
|
||||
role="presentation"
|
||||
onpointerdown={() => {
|
||||
if (interactive) onselect?.(undefined);
|
||||
}}
|
||||
>
|
||||
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
|
||||
|
||||
@@ -185,9 +188,12 @@
|
||||
{assets}
|
||||
{assetOverrides}
|
||||
{interactive}
|
||||
{selectedId}
|
||||
{selectedIds}
|
||||
{scale}
|
||||
{onselect}
|
||||
{onselectmany}
|
||||
{onmove}
|
||||
{onpatch}
|
||||
{oncontext}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { custom_theme_volume } from '$lib/global';
|
||||
import { themeMediaSource } from '$lib/themeMedia';
|
||||
import { themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
|
||||
import type { ThemeV2Definition } from '$lib/themeV2';
|
||||
import { themeAssetUrl } from '$lib/themeV2';
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
@@ -116,6 +116,7 @@
|
||||
<video
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
use:themeMediaSource={assetUrl(background.src)}
|
||||
use:themeVideoLifecycle
|
||||
autoplay
|
||||
loop
|
||||
muted={background.muted !== false}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { fontStack } from '$lib/themeFonts';
|
||||
import { themeMediaSource } from '$lib/themeMedia';
|
||||
import { themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
|
||||
import { themeAssetUrl, type ThemeV2Element } from '$lib/themeV2';
|
||||
|
||||
let {
|
||||
@@ -8,22 +8,29 @@
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
interactive = false,
|
||||
selectedId = undefined,
|
||||
selectedIds = [],
|
||||
scale = 1,
|
||||
onselect,
|
||||
onselectmany,
|
||||
onmove,
|
||||
onpatch,
|
||||
oncontext,
|
||||
}: {
|
||||
elements?: ThemeV2Element[];
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
interactive?: boolean;
|
||||
selectedId?: string;
|
||||
selectedIds?: string[];
|
||||
scale?: number;
|
||||
onselect?: (id: string | undefined) => void;
|
||||
onselect?: (id: string | undefined, additive?: boolean) => void;
|
||||
onselectmany?: (ids: string[], additive?: boolean) => void;
|
||||
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
|
||||
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||
} = $props();
|
||||
|
||||
const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
|
||||
const visibleElements = $derived(elements.filter((element) => !element.hidden));
|
||||
|
||||
// Keep the live muted property in sync, including live editor toggles.
|
||||
function applyMuted(node: HTMLVideoElement, muted: boolean) {
|
||||
@@ -35,68 +42,209 @@
|
||||
};
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
type ElementOrigin = {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
ids: string[];
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
origins: ElementOrigin[];
|
||||
};
|
||||
|
||||
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se';
|
||||
|
||||
type ResizeState = DragState & {
|
||||
type ResizeState = {
|
||||
id: string;
|
||||
direction: ResizeDirection;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
originWidth: number;
|
||||
originHeight: number;
|
||||
};
|
||||
|
||||
const resizeDirections: ResizeDirection[] = ['nw', 'ne', 'sw', 'se'];
|
||||
type MarqueeState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
additive: boolean;
|
||||
};
|
||||
|
||||
const resizeDirections: ResizeDirection[] = ['nw', 'ne', 'sw', 'se'];
|
||||
const SNAP_DISTANCE = 6;
|
||||
|
||||
let overlay = $state<HTMLDivElement | undefined>(undefined);
|
||||
let drag = $state<DragState | null>(null);
|
||||
let resize = $state<ResizeState | null>(null);
|
||||
let marquee = $state<MarqueeState | null>(null);
|
||||
let snapGuides = $state<{ x?: number; y?: number }>({});
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const selectedSet = $derived(new Set(selectedIds));
|
||||
|
||||
const isSelected = (id: string) => interactive && selectedSet.has(id);
|
||||
|
||||
const modifierPressed = (event: PointerEvent | KeyboardEvent) =>
|
||||
event.shiftKey || event.metaKey || event.ctrlKey;
|
||||
|
||||
function selectElement(id: string | undefined, additive = false) {
|
||||
onselect?.(id, additive);
|
||||
}
|
||||
|
||||
function beginDrag(event: PointerEvent, element: ThemeV2Element) {
|
||||
if (!interactive || (event.target as HTMLElement).dataset.resize) return;
|
||||
if (!interactive || event.button !== 0) return;
|
||||
if ((event.target as HTMLElement).dataset.resize) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onselect?.(element.id);
|
||||
|
||||
const additive = modifierPressed(event);
|
||||
if (additive) {
|
||||
selectElement(element.id, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.locked) {
|
||||
selectElement(element.id, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const keepGroup = selectedSet.has(element.id) && selectedIds.length > 1;
|
||||
if (!keepGroup) selectElement(element.id, false);
|
||||
|
||||
const candidateIds = keepGroup ? [...selectedIds] : [element.id];
|
||||
const origins = candidateIds
|
||||
.map((id) => elements.find((candidate) => candidate.id === id))
|
||||
.filter(
|
||||
(candidate): candidate is ThemeV2Element => candidate !== undefined && !candidate.locked
|
||||
)
|
||||
.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
x: candidate.x,
|
||||
y: candidate.y,
|
||||
width: candidate.width,
|
||||
height: candidate.height,
|
||||
}));
|
||||
|
||||
if (origins.length === 0) return;
|
||||
|
||||
drag = {
|
||||
id: element.id,
|
||||
ids: origins.map((origin) => origin.id),
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: element.x,
|
||||
originY: element.y,
|
||||
origins,
|
||||
};
|
||||
snapGuides = {};
|
||||
window.addEventListener('pointermove', moveDrag);
|
||||
window.addEventListener('pointerup', endDrag, { once: true });
|
||||
}
|
||||
|
||||
function groupBounds(origins: ElementOrigin[]) {
|
||||
const left = Math.min(...origins.map((origin) => origin.x));
|
||||
const top = Math.min(...origins.map((origin) => origin.y));
|
||||
const right = Math.max(...origins.map((origin) => origin.x + origin.width));
|
||||
const bottom = Math.max(...origins.map((origin) => origin.y + origin.height));
|
||||
return { left, top, right, bottom, centerX: (left + right) / 2, centerY: (top + bottom) / 2 };
|
||||
}
|
||||
|
||||
function closestSnap(
|
||||
lines: number[],
|
||||
candidates: number[]
|
||||
): { delta: number; guide: number } | undefined {
|
||||
let best: { delta: number; guide: number } | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
for (const candidate of candidates) {
|
||||
const delta = candidate - line;
|
||||
if (Math.abs(delta) > SNAP_DISTANCE) continue;
|
||||
if (!best || Math.abs(delta) < Math.abs(best.delta)) {
|
||||
best = { delta, guide: candidate };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
function moveDrag(event: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const element = elements.find((candidate) => candidate.id === drag!.id);
|
||||
if (!element) return;
|
||||
const dx = (event.clientX - drag.startX) / scale;
|
||||
const dy = (event.clientY - drag.startY) / scale;
|
||||
onpatch?.(drag.id, {
|
||||
x: Math.round(clamp(drag.originX + dx, 0, 1000 - element.width)),
|
||||
y: Math.round(clamp(drag.originY + dy, 0, 650 - element.height)),
|
||||
});
|
||||
|
||||
const bounds = groupBounds(drag.origins);
|
||||
const rawDx = (event.clientX - drag.startX) / scale;
|
||||
const rawDy = (event.clientY - drag.startY) / scale;
|
||||
let dx = clamp(rawDx, -bounds.left, 1000 - bounds.right);
|
||||
let dy = clamp(rawDy, -bounds.top, 650 - bounds.bottom);
|
||||
|
||||
if (!event.altKey) {
|
||||
const others = visibleElements.filter((element) => !drag!.ids.includes(element.id));
|
||||
const xCandidates = [
|
||||
0,
|
||||
500,
|
||||
1000,
|
||||
...others.flatMap((element) => [
|
||||
element.x,
|
||||
element.x + element.width / 2,
|
||||
element.x + element.width,
|
||||
]),
|
||||
];
|
||||
const yCandidates = [
|
||||
0,
|
||||
325,
|
||||
650,
|
||||
...others.flatMap((element) => [
|
||||
element.y,
|
||||
element.y + element.height / 2,
|
||||
element.y + element.height,
|
||||
]),
|
||||
];
|
||||
|
||||
const xSnap = closestSnap(
|
||||
[bounds.left + dx, bounds.centerX + dx, bounds.right + dx],
|
||||
xCandidates
|
||||
);
|
||||
const ySnap = closestSnap(
|
||||
[bounds.top + dy, bounds.centerY + dy, bounds.bottom + dy],
|
||||
yCandidates
|
||||
);
|
||||
|
||||
if (xSnap) dx += xSnap.delta;
|
||||
if (ySnap) dy += ySnap.delta;
|
||||
snapGuides = { x: xSnap?.guide, y: ySnap?.guide };
|
||||
} else {
|
||||
snapGuides = {};
|
||||
}
|
||||
|
||||
dx = clamp(dx, -bounds.left, 1000 - bounds.right);
|
||||
dy = clamp(dy, -bounds.top, 650 - bounds.bottom);
|
||||
|
||||
onmove?.(
|
||||
drag.origins.map((origin) => ({
|
||||
id: origin.id,
|
||||
x: Math.round(clamp(origin.x + dx, 0, 1000 - origin.width)),
|
||||
y: Math.round(clamp(origin.y + dy, 0, 650 - origin.height)),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
drag = null;
|
||||
snapGuides = {};
|
||||
window.removeEventListener('pointermove', moveDrag);
|
||||
}
|
||||
|
||||
function beginResize(
|
||||
event: PointerEvent,
|
||||
element: ThemeV2Element,
|
||||
direction: ResizeState['direction']
|
||||
) {
|
||||
if (!interactive) return;
|
||||
function beginResize(event: PointerEvent, element: ThemeV2Element, direction: ResizeDirection) {
|
||||
if (!interactive || element.locked) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resize = {
|
||||
@@ -137,30 +285,100 @@
|
||||
resize = null;
|
||||
window.removeEventListener('pointermove', moveResize);
|
||||
}
|
||||
|
||||
function beginMarquee(event: PointerEvent) {
|
||||
if (!interactive || event.button !== 0 || event.target !== event.currentTarget || !overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
const startX = clamp((event.clientX - rect.left) / scale, 0, 1000);
|
||||
const startY = clamp((event.clientY - rect.top) / scale, 0, 650);
|
||||
const additive = modifierPressed(event);
|
||||
|
||||
if (!additive) onselectmany?.([], false);
|
||||
|
||||
marquee = { startX, startY, x: startX, y: startY, width: 0, height: 0, additive };
|
||||
window.addEventListener('pointermove', moveMarquee);
|
||||
window.addEventListener('pointerup', endMarquee, { once: true });
|
||||
}
|
||||
|
||||
function moveMarquee(event: PointerEvent) {
|
||||
if (!marquee || !overlay) return;
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
const currentX = clamp((event.clientX - rect.left) / scale, 0, 1000);
|
||||
const currentY = clamp((event.clientY - rect.top) / scale, 0, 650);
|
||||
|
||||
marquee = {
|
||||
...marquee,
|
||||
x: Math.min(marquee.startX, currentX),
|
||||
y: Math.min(marquee.startY, currentY),
|
||||
width: Math.abs(currentX - marquee.startX),
|
||||
height: Math.abs(currentY - marquee.startY),
|
||||
};
|
||||
}
|
||||
|
||||
function endMarquee() {
|
||||
if (!marquee) return;
|
||||
const selection = marquee;
|
||||
marquee = null;
|
||||
window.removeEventListener('pointermove', moveMarquee);
|
||||
|
||||
if (selection.width < 3 && selection.height < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hits = visibleElements
|
||||
.filter(
|
||||
(element) =>
|
||||
!element.locked &&
|
||||
element.x < selection.x + selection.width &&
|
||||
element.x + element.width > selection.x &&
|
||||
element.y < selection.y + selection.height &&
|
||||
element.y + element.height > selection.y
|
||||
)
|
||||
.map((element) => element.id);
|
||||
|
||||
onselectmany?.(hits, selection.additive);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={overlay}
|
||||
class="absolute inset-0 z-20 {interactive ? 'pointer-events-auto' : 'pointer-events-none'}"
|
||||
role="presentation"
|
||||
onpointerdown={beginMarquee}
|
||||
oncontextmenu={(event) => {
|
||||
if (!interactive || event.target !== event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
oncontext?.(undefined, event);
|
||||
}}
|
||||
>
|
||||
{#each elements as element (element.id)}
|
||||
{@const isSelected = interactive && element.id === selectedId}
|
||||
{#each visibleElements as element (element.id)}
|
||||
{@const selected = isSelected(element.id)}
|
||||
{@const singleSelected = selected && selectedIds.length === 1}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="absolute select-none"
|
||||
class:cursor-move={interactive}
|
||||
class:cursor-move={interactive && !element.locked}
|
||||
class:cursor-default={interactive && element.locked}
|
||||
style="left:{element.x}px;top:{element.y}px;width:{element.width}px;height:{element.height}px;transform:rotate({element.rotation ??
|
||||
0}deg);opacity:{element.opacity ?? 1};touch-action:none;outline:{isSelected
|
||||
? '2px solid #ff006e'
|
||||
0}deg);opacity:{element.opacity ?? 1};touch-action:none;outline:{selected
|
||||
? `${selectedIds.length > 1 ? 1 : 2}px solid #ff006e`
|
||||
: 'none'};outline-offset:2px;"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : -1}
|
||||
aria-label={interactive ? `Select ${element.type} layer` : undefined}
|
||||
onpointerdown={(event) => beginDrag(event, element)}
|
||||
oncontextmenu={(event) => {
|
||||
if (!interactive) return;
|
||||
event.preventDefault();
|
||||
oncontext?.(element.id, event);
|
||||
}}
|
||||
onkeydown={(event) => {
|
||||
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
onselect?.(element.id);
|
||||
selectElement(element.id, modifierPressed(event));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -199,6 +417,7 @@
|
||||
<video
|
||||
class="pointer-events-none h-full w-full"
|
||||
use:themeMediaSource={assetUrl(element.src)}
|
||||
use:themeVideoLifecycle
|
||||
autoplay
|
||||
loop
|
||||
muted={element.muted !== false}
|
||||
@@ -219,7 +438,7 @@
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if isSelected}
|
||||
{#if singleSelected && !element.locked}
|
||||
{#each resizeDirections as direction (direction)}
|
||||
<span
|
||||
class="absolute z-10 block size-3 rounded-full border-2 border-white bg-primary shadow"
|
||||
@@ -239,6 +458,34 @@
|
||||
{element.type}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if selected && selectedIds.length > 1}
|
||||
<span
|
||||
class="absolute -top-5 left-0 rounded bg-primary/90 px-1 py-0.5 text-[8px] font-semibold text-primary-foreground"
|
||||
>
|
||||
selected
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if snapGuides.x !== undefined}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-30 w-px bg-primary/80"
|
||||
style="left:{snapGuides.x}px"
|
||||
></div>
|
||||
{/if}
|
||||
{#if snapGuides.y !== undefined}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 z-30 h-px bg-primary/80"
|
||||
style="top:{snapGuides.y}px"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if marquee}
|
||||
<div
|
||||
class="pointer-events-none absolute z-30 border border-primary bg-primary/10"
|
||||
style="left:{marquee.x}px;top:{marquee.y}px;width:{marquee.width}px;height:{marquee.height}px;"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
inset,
|
||||
children: childrenProp,
|
||||
closeOnSelect = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
|
||||
inset?: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
bind:ref
|
||||
{closeOnSelect}
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.()}
|
||||
{/snippet}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import ContextMenuPortal from './context-menu-portal.svelte';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
portalProps,
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof ContextMenuPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPortal {...portalProps}>
|
||||
<ContextMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="context-menu-content"
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--bits-context-menu-content-available-height) origin-(--bits-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</ContextMenuPortal>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.GroupHeadingProps & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="context-menu-group-heading"
|
||||
data-inset={inset}
|
||||
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Group bind:ref data-slot="context-menu-group" {...restProps} />
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: ContextMenuPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(''),
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.RadioGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="context-menu-radio-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children: childrenProp,
|
||||
closeOnSelect = false,
|
||||
...restProps
|
||||
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
bind:ref
|
||||
{closeOnSelect}
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.({ checked })}
|
||||
{/snippet}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="context-menu-separator"
|
||||
class={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="context-menu-shortcut"
|
||||
class={cn(
|
||||
'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import ContextMenuPortal from './context-menu-portal.svelte';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
align = 'start',
|
||||
alignOffset = -3,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.SubContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof ContextMenuPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPortal {...portalProps}>
|
||||
<ContextMenuPrimitive.SubContent
|
||||
bind:ref
|
||||
data-slot="context-menu-sub-content"
|
||||
{align}
|
||||
{alignOffset}
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-32 rounded-lg border p-1 shadow-lg duration-100 z-50 origin-(--bits-context-menu-content-transform-origin) overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</ContextMenuPortal>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
bind:ref
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.SubProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Sub bind:open {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="context-menu-trigger"
|
||||
class={cn('select-none', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Root bind:open {...restProps} />
|
||||
@@ -0,0 +1,52 @@
|
||||
import CheckboxItem from './context-menu-checkbox-item.svelte';
|
||||
import Content from './context-menu-content.svelte';
|
||||
import GroupHeading from './context-menu-group-heading.svelte';
|
||||
import Group from './context-menu-group.svelte';
|
||||
import Item from './context-menu-item.svelte';
|
||||
import Label from './context-menu-label.svelte';
|
||||
import Portal from './context-menu-portal.svelte';
|
||||
import RadioGroup from './context-menu-radio-group.svelte';
|
||||
import RadioItem from './context-menu-radio-item.svelte';
|
||||
import Separator from './context-menu-separator.svelte';
|
||||
import Shortcut from './context-menu-shortcut.svelte';
|
||||
import SubContent from './context-menu-sub-content.svelte';
|
||||
import SubTrigger from './context-menu-sub-trigger.svelte';
|
||||
import Sub from './context-menu-sub.svelte';
|
||||
import Trigger from './context-menu-trigger.svelte';
|
||||
import Root from './context-menu.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Sub,
|
||||
Portal,
|
||||
Item,
|
||||
GroupHeading,
|
||||
Label,
|
||||
Group,
|
||||
Trigger,
|
||||
Content,
|
||||
Shortcut,
|
||||
Separator,
|
||||
RadioItem,
|
||||
SubContent,
|
||||
SubTrigger,
|
||||
RadioGroup,
|
||||
CheckboxItem,
|
||||
//
|
||||
Root as ContextMenu,
|
||||
Sub as ContextMenuSub,
|
||||
Portal as ContextMenuPortal,
|
||||
Item as ContextMenuItem,
|
||||
GroupHeading as ContextMenuGroupHeading,
|
||||
Group as ContextMenuGroup,
|
||||
Content as ContextMenuContent,
|
||||
Trigger as ContextMenuTrigger,
|
||||
Shortcut as ContextMenuShortcut,
|
||||
RadioItem as ContextMenuRadioItem,
|
||||
Separator as ContextMenuSeparator,
|
||||
RadioGroup as ContextMenuRadioGroup,
|
||||
SubContent as ContextMenuSubContent,
|
||||
SubTrigger as ContextMenuSubTrigger,
|
||||
CheckboxItem as ContextMenuCheckboxItem,
|
||||
Label as ContextMenuLabel,
|
||||
};
|
||||
+202
-19
@@ -1,35 +1,131 @@
|
||||
/**
|
||||
* Theme media playback helper.
|
||||
*
|
||||
* On macOS, WKWebView's media pipeline asks Tauri's custom `asset:` protocol
|
||||
* for thousands of one-byte ranges when it plays a WebM file. That request
|
||||
* storm starves the audio decoder, which makes the audio stutter or drop out.
|
||||
* Fetching the file once and handing the media element a `blob:` URL avoids
|
||||
* the custom protocol entirely and lets WebKit decode from a local buffer.
|
||||
* Linux and macOS mainly use the loopback theme server, which supports byte
|
||||
* ranges and streams large videos without loading them into memory. This file
|
||||
* still provides a blob fallback for macOS when an asset URL must be used
|
||||
* directly. WKWebView asks Tauri's custom `asset:` protocol for thousands of
|
||||
* one-byte ranges, which can starve the audio decoder. A blob avoids that, but
|
||||
* WebKit's blob-backed media path starts stuttering past roughly 30 MB, so
|
||||
* large files are left on the direct URL instead.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
type MediaCleanup = () => void;
|
||||
|
||||
const assetPattern = /^(?:asset:|https?:\/\/asset\.localhost(?:\/|$))/i;
|
||||
const loopbackThemePattern = /^http:\/\/127\.0\.0\.1:\d+\/themes\//i;
|
||||
const videoFilePattern = /\.(?:webm|mp4|m4v|mov|ogv|mkv)(?:[?#]|$)/i;
|
||||
const MAX_BLOB_BYTES = 24 * 1024 * 1024;
|
||||
const OPTIMIZE_AFTER_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
// The request-storm problem is specific to WKWebView. Keep the native
|
||||
// streaming path everywhere else, where the asset protocol behaves correctly
|
||||
// (and where blob-backed media can fail to decode in WebKitGTK).
|
||||
const useBlobMedia =
|
||||
typeof navigator !== 'undefined' && /Macintosh|Mac OS X/i.test(navigator.userAgent);
|
||||
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/i.test(navigator.userAgent);
|
||||
const useBlobMedia = isMac;
|
||||
|
||||
/** True while a large theme video is being prepared for hardware playback. */
|
||||
export const themeMediaPreparing = writable(false);
|
||||
/** Human-readable detail for the preparation overlay. */
|
||||
export const themeMediaStatus = writable('');
|
||||
|
||||
type ThemeMediaProgress = {
|
||||
stage: 'download' | 'extract' | 'optimize';
|
||||
progress: number;
|
||||
};
|
||||
|
||||
let preparingCount = 0;
|
||||
let statusListenerStarted = false;
|
||||
|
||||
const setPreparing = (delta: number) => {
|
||||
preparingCount = Math.max(0, preparingCount + delta);
|
||||
themeMediaPreparing.set(preparingCount > 0);
|
||||
if (preparingCount === 0) themeMediaStatus.set('');
|
||||
};
|
||||
|
||||
const ensureStatusListener = async () => {
|
||||
if (statusListenerStarted || typeof window === 'undefined') return;
|
||||
statusListenerStarted = true;
|
||||
|
||||
try {
|
||||
await listen<ThemeMediaProgress>('theme-media-progress', (event) => {
|
||||
const { stage, progress } = event.payload;
|
||||
if (stage === 'download') {
|
||||
themeMediaStatus.set(`Downloading video engine… ${Math.round(progress * 100)}%`);
|
||||
} else if (stage === 'extract') {
|
||||
themeMediaStatus.set('Installing video engine…');
|
||||
} else if (stage === 'optimize') {
|
||||
themeMediaStatus.set('Converting theme video for hardware playback…');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Could not listen for theme video preparation progress', error);
|
||||
}
|
||||
};
|
||||
|
||||
const blobUrlPromises = new Map<string, Promise<string>>();
|
||||
const optimizedUrlPromises = new Map<string, Promise<string>>();
|
||||
const objectUrls = new Set<string>();
|
||||
const mediaTokens = new WeakMap<HTMLMediaElement, number>();
|
||||
const mediaCleanups = new WeakMap<HTMLMediaElement, MediaCleanup>();
|
||||
const autoplayArmed = new WeakSet<HTMLMediaElement>();
|
||||
let tokenCounter = 0;
|
||||
let cacheGeneration = 0;
|
||||
|
||||
export const isThemeAssetUrl = (url: string | undefined | null): url is string =>
|
||||
!!url && assetPattern.test(url);
|
||||
|
||||
const isLoopbackThemeVideo = (source: string) =>
|
||||
isMac && loopbackThemePattern.test(source) && videoFilePattern.test(source);
|
||||
|
||||
/**
|
||||
* WebKit's VP9 decoder is software-only on macOS, and a large 1080p theme
|
||||
* video can starve the audio decoder. Ask the launcher for a cached 720p
|
||||
* H.264/AAC version, which the macOS media engine decodes in hardware.
|
||||
*/
|
||||
const optimizedThemeMediaUrl = async (source: string): Promise<string> => {
|
||||
if (!isLoopbackThemeVideo(source)) return source;
|
||||
|
||||
const cached = optimizedUrlPromises.get(source);
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const head = await fetch(source, { method: 'HEAD', cache: 'no-store' });
|
||||
const declaredLength = Number(head.headers.get('content-length'));
|
||||
if (!head.ok || !Number.isFinite(declaredLength) || declaredLength < OPTIMIZE_AFTER_BYTES) {
|
||||
return source;
|
||||
}
|
||||
|
||||
setPreparing(1);
|
||||
themeMediaStatus.set('Preparing theme video for smooth playback…');
|
||||
await ensureStatusListener();
|
||||
try {
|
||||
const optimized = await invoke<string>('optimize_theme_media', {
|
||||
sourceUrl: source,
|
||||
});
|
||||
return optimized || source;
|
||||
} finally {
|
||||
setPreparing(-1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not optimize theme video for hardware playback', error);
|
||||
return source;
|
||||
}
|
||||
})();
|
||||
|
||||
optimizedUrlPromises.set(source, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
/** Resolve a theme asset to a blob URL, falling back to the original URL. */
|
||||
export const themeMediaUrl = async (source: string): Promise<string> => {
|
||||
const optimized = await optimizedThemeMediaUrl(source);
|
||||
if (optimized !== source) return optimized;
|
||||
|
||||
if (!useBlobMedia || !isThemeAssetUrl(source)) return source;
|
||||
|
||||
const cached = blobUrlPromises.get(source);
|
||||
@@ -41,6 +137,12 @@ export const themeMediaUrl = async (source: string): Promise<string> => {
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) throw new Error(`Theme media request failed (${response.status})`);
|
||||
|
||||
const declaredLength = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_BLOB_BYTES) {
|
||||
void response.body?.cancel();
|
||||
return source;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
@@ -67,10 +169,44 @@ export const themeMediaUrl = async (source: string): Promise<string> => {
|
||||
export const clearThemeMediaCache = () => {
|
||||
cacheGeneration += 1;
|
||||
blobUrlPromises.clear();
|
||||
optimizedUrlPromises.clear();
|
||||
for (const url of objectUrls) URL.revokeObjectURL(url);
|
||||
objectUrls.clear();
|
||||
};
|
||||
|
||||
/** Retry playback on the first click or key press after an autoplay rejection. */
|
||||
const armAutoplayRetry = (node: HTMLMediaElement) => {
|
||||
if (autoplayArmed.has(node)) return;
|
||||
autoplayArmed.add(node);
|
||||
|
||||
const retry = () => {
|
||||
document.removeEventListener('pointerdown', retry, true);
|
||||
document.removeEventListener('keydown', retry, true);
|
||||
autoplayArmed.delete(node);
|
||||
if (node.isConnected) void node.play().catch(() => {});
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', retry, true);
|
||||
document.addEventListener('keydown', retry, true);
|
||||
};
|
||||
|
||||
const playThemeMedia = (node: HTMLMediaElement) => {
|
||||
void node
|
||||
.play()
|
||||
.then(() => {
|
||||
console.debug('Theme media playing', {
|
||||
element: node.tagName,
|
||||
muted: node.muted,
|
||||
volume: node.volume,
|
||||
source: node.currentSrc,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Theme media autoplay was blocked', error);
|
||||
armAutoplayRetry(node);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Swap a media element over to a blob URL while keeping its playback position
|
||||
* and state. `source` must be the original asset URL, not the blob URL.
|
||||
@@ -81,12 +217,21 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
|
||||
mediaCleanups.get(node)?.();
|
||||
mediaCleanups.delete(node);
|
||||
|
||||
const previousTime = Number.isFinite(node.currentTime) ? node.currentTime : 0;
|
||||
const wasPlaying = !node.paused;
|
||||
const needsPreparation = node instanceof HTMLVideoElement && isLoopbackThemeVideo(source);
|
||||
|
||||
// Keep the last frame visible while the first H.264 encode runs instead of
|
||||
// letting WebKit struggle through the software VP9 track.
|
||||
if (needsPreparation) node.pause();
|
||||
|
||||
void themeMediaUrl(source).then((url) => {
|
||||
if (mediaTokens.get(node) !== token || !node.isConnected) return;
|
||||
if (node.src === url) return;
|
||||
|
||||
const wasPlaying = !node.paused;
|
||||
const previousTime = Number.isFinite(node.currentTime) ? node.currentTime : 0;
|
||||
if (node.src === url) {
|
||||
if (wasPlaying || node.autoplay) playThemeMedia(node);
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
node.removeEventListener('loadedmetadata', onLoaded);
|
||||
@@ -105,22 +250,19 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (wasPlaying || node.autoplay) {
|
||||
void node.play().catch(() => {
|
||||
// Autoplay can be rejected until the user interacts with the window.
|
||||
});
|
||||
}
|
||||
if (wasPlaying || node.autoplay) playThemeMedia(node);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
mediaCleanups.delete(node);
|
||||
|
||||
// Keep the theme usable if this WebView cannot decode the blob.
|
||||
if (isThemeAssetUrl(source) && node.src !== source) {
|
||||
// Keep the theme usable if this WebView cannot decode the blob or the
|
||||
// optimized file.
|
||||
if (node.src !== source) {
|
||||
node.src = source;
|
||||
node.load();
|
||||
if (wasPlaying || node.autoplay) void node.play().catch(() => {});
|
||||
if (wasPlaying || node.autoplay) playThemeMedia(node);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,6 +275,46 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause background videos while the launcher is hidden or unfocused, then
|
||||
* resume only the videos this helper paused itself. This keeps muted theme
|
||||
* videos from decoding in the background and prevents surprise audio when the
|
||||
* window comes back.
|
||||
*/
|
||||
export function themeVideoLifecycle(node: HTMLVideoElement) {
|
||||
let pausedByLifecycle = false;
|
||||
|
||||
const pause = () => {
|
||||
if (node.paused) return;
|
||||
pausedByLifecycle = true;
|
||||
node.pause();
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
if (!pausedByLifecycle) return;
|
||||
pausedByLifecycle = false;
|
||||
playThemeMedia(node);
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
if (document.visibilityState === 'visible' && document.hasFocus()) resume();
|
||||
else pause();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
window.addEventListener('focus', sync);
|
||||
window.addEventListener('blur', sync);
|
||||
sync();
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('visibilitychange', sync);
|
||||
window.removeEventListener('focus', sync);
|
||||
window.removeEventListener('blur', sync);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action for v2 theme videos/audio. Use this instead of `src=...`.
|
||||
*/
|
||||
@@ -166,7 +348,8 @@ export function smoothThemeMedia(node: HTMLElement) {
|
||||
media.getAttribute('src') ??
|
||||
media.querySelector<HTMLSourceElement>('source[src]')?.getAttribute('src') ??
|
||||
undefined;
|
||||
if (!isThemeAssetUrl(source)) return;
|
||||
if (!source) return;
|
||||
if (!isThemeAssetUrl(source) && !loopbackThemePattern.test(source)) return;
|
||||
if (lastSource.get(media) === source) return;
|
||||
|
||||
lastSource.set(media, source);
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export const renderThemePreview = async ({
|
||||
drawStatsCard(context);
|
||||
drawFooter(context);
|
||||
for (const element of definition.elements ?? []) {
|
||||
if (element.hidden) continue;
|
||||
await drawElement(context, element);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Theme } from '$lib/themes';
|
||||
|
||||
/**
|
||||
* API 1.0 themes are executable bundles: loading one imports its entry script
|
||||
* into the launcher window. API 2.0 themes are declarative data and do not
|
||||
* need a trust decision.
|
||||
*/
|
||||
const TRUSTED_THEMES_KEY = 'ezpplauncher.trusted-v1-themes';
|
||||
|
||||
export const themeRunsCode = (theme: Pick<Theme, 'apiVersion' | 'scriptUrl'>) =>
|
||||
theme.apiVersion.split('.')[0] === '1' && theme.scriptUrl.length > 0;
|
||||
|
||||
export const themeTrustKey = (theme: Pick<Theme, 'folder_name' | 'version'>) =>
|
||||
`${theme.folder_name}@${theme.version}`;
|
||||
|
||||
function readTrustedThemes(): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(TRUSTED_THEMES_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const isThemeTrusted = (theme: Theme) =>
|
||||
!themeRunsCode(theme) || readTrustedThemes().includes(themeTrustKey(theme));
|
||||
|
||||
export const trustTheme = (theme: Theme) => {
|
||||
if (!themeRunsCode(theme) || typeof window === 'undefined') return;
|
||||
const trusted = readTrustedThemes();
|
||||
const key = themeTrustKey(theme);
|
||||
if (!trusted.includes(key)) {
|
||||
localStorage.setItem(TRUSTED_THEMES_KEY, JSON.stringify([...trusted, key]));
|
||||
}
|
||||
};
|
||||
@@ -81,6 +81,8 @@ export type ThemeV2ElementVerticalAlign = 'top' | 'center' | 'bottom';
|
||||
|
||||
export type ThemeV2ElementBase = {
|
||||
id: string;
|
||||
/** Optional editor-facing name; does not affect rendering. */
|
||||
name?: string;
|
||||
/** Stage coordinates: the launcher canvas is 1000 x 650. */
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -88,6 +90,10 @@ export type ThemeV2ElementBase = {
|
||||
height: number;
|
||||
rotation?: number;
|
||||
opacity?: number;
|
||||
/** Hidden layers stay in the package but are not rendered. */
|
||||
hidden?: boolean;
|
||||
/** Locked layers can be selected in the layer list but not dragged on canvas. */
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
export type ThemeV2TextElement = ThemeV2ElementBase & {
|
||||
@@ -222,12 +228,15 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
|
||||
const type = oneOf(source.type, ['text', 'image', 'video', 'shape'] as const, 'text');
|
||||
const base = {
|
||||
id: text(source.id, `element_${index + 1}`).slice(0, 64),
|
||||
name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined,
|
||||
x: clamp(source.x, 0, 0, 1000),
|
||||
y: clamp(source.y, 0, 0, 650),
|
||||
width: clamp(source.width, 320, 24, 1000),
|
||||
height: clamp(source.height, 120, 24, 650),
|
||||
rotation: clamp(source.rotation, 0, -360, 360),
|
||||
opacity: clamp(source.opacity, 1, 0, 1),
|
||||
hidden: source.hidden === true,
|
||||
locked: source.locked === true,
|
||||
};
|
||||
|
||||
if (type === 'text') {
|
||||
|
||||
+79
-9
@@ -24,7 +24,17 @@ export type Theme = {
|
||||
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
|
||||
progress: number;
|
||||
updateAvailable: boolean;
|
||||
/** The version available in the repository when an update is pending. */
|
||||
availableVersion?: string;
|
||||
definition?: ThemeV2Definition;
|
||||
backups?: ThemeBackup[];
|
||||
};
|
||||
|
||||
export type ThemeBackup = {
|
||||
path: string;
|
||||
label: string;
|
||||
createdAt: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type ThemeInfo = {
|
||||
@@ -239,11 +249,26 @@ export const getThemes = async (): Promise<Theme[]> => {
|
||||
if (await fs.exists(folderPath)) {
|
||||
const themeFolders = await fs.readDir(folderPath);
|
||||
for (const themeFolder of themeFolders) {
|
||||
const theme = await themeFromFolder(
|
||||
themeFolder.name,
|
||||
await path.join(folderPath, themeFolder.name)
|
||||
);
|
||||
if (theme) themes.push(theme);
|
||||
// Finder drops .DS_Store into this folder; never let a stray file or an
|
||||
// unreadable package hide every other installed theme.
|
||||
if (!themeFolder.isDirectory || themeFolder.name.startsWith('.')) continue;
|
||||
|
||||
const themeFolderPath = await path.join(folderPath, themeFolder.name);
|
||||
try {
|
||||
const theme = await themeFromFolder(themeFolder.name, themeFolderPath);
|
||||
if (theme) {
|
||||
try {
|
||||
theme.backups = await invoke<ThemeBackup[]>('list_theme_backups', {
|
||||
themeFolder: themeFolderPath,
|
||||
});
|
||||
} catch {
|
||||
theme.backups = [];
|
||||
}
|
||||
themes.push(theme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load theme ${themeFolder.name}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return themes;
|
||||
@@ -254,6 +279,7 @@ export const downloadTheme = async (
|
||||
force = false
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
if (theme.status !== 'not-installed' && !force) return { success: false };
|
||||
const wasInstalled = theme.status === 'installed';
|
||||
|
||||
patchTheme(theme.name, (current) => ({ ...current, status: 'downloading' }));
|
||||
|
||||
@@ -265,7 +291,10 @@ export const downloadTheme = async (
|
||||
);
|
||||
|
||||
if (themeContents.error) {
|
||||
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
|
||||
patchTheme(theme.name, (current) => ({
|
||||
...current,
|
||||
status: wasInstalled ? 'installed' : 'not-installed',
|
||||
}));
|
||||
return {
|
||||
success: false,
|
||||
error: themeContents.error.message ?? 'An unknown error occurred',
|
||||
@@ -286,6 +315,19 @@ export const downloadTheme = async (
|
||||
progress: 0,
|
||||
}));
|
||||
|
||||
let updateBackup: string | undefined;
|
||||
const restoreUpdateBackup = async () => {
|
||||
if (!updateBackup) return;
|
||||
try {
|
||||
await invoke('restore_theme_backup', {
|
||||
themeFolder,
|
||||
backupFolder: updateBackup,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Could not restore the theme backup after a failed update', error);
|
||||
}
|
||||
};
|
||||
|
||||
let extractionStartedAt = 0;
|
||||
const beginExtraction = (progress: number) => {
|
||||
if (extractionStartedAt === 0) extractionStartedAt = Date.now();
|
||||
@@ -318,6 +360,16 @@ export const downloadTheme = async (
|
||||
});
|
||||
|
||||
try {
|
||||
// Keep the currently installed files before an update overwrites them.
|
||||
// The backup also covers a failed/partial download so the user can roll back.
|
||||
if (theme.status === 'installed') {
|
||||
try {
|
||||
updateBackup = await invoke<string>('backup_theme_folder', { themeFolder });
|
||||
} catch (error) {
|
||||
console.warn('Could not back up the theme before updating it', error);
|
||||
}
|
||||
}
|
||||
|
||||
await invoke('download_and_extract_theme', {
|
||||
downloadUrl,
|
||||
expectedSha: themeContents.data.file_contents.sha,
|
||||
@@ -327,7 +379,11 @@ export const downloadTheme = async (
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
const erro = err as Error;
|
||||
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
|
||||
await restoreUpdateBackup();
|
||||
patchTheme(theme.name, (current) => ({
|
||||
...current,
|
||||
status: wasInstalled ? 'installed' : 'not-installed',
|
||||
}));
|
||||
return { success: false, error: erro.message || 'Failed to download or extract theme.' };
|
||||
} finally {
|
||||
unlistenDownload();
|
||||
@@ -349,7 +405,11 @@ export const downloadTheme = async (
|
||||
|
||||
const installedTheme = await themeFromFolder(folderName, themeFolder);
|
||||
if (!installedTheme) {
|
||||
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
|
||||
await restoreUpdateBackup();
|
||||
patchTheme(theme.name, (current) => ({
|
||||
...current,
|
||||
status: wasInstalled ? 'installed' : 'not-installed',
|
||||
}));
|
||||
return { success: false, error: 'The downloaded theme package is invalid.' };
|
||||
}
|
||||
|
||||
@@ -478,7 +538,9 @@ export const reloadThemes = async () => {
|
||||
if (installed) {
|
||||
try {
|
||||
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
|
||||
combinedMap.get(downloadable.name)!.updateAvailable = true;
|
||||
const installedTheme = combinedMap.get(downloadable.name)!;
|
||||
installedTheme.updateAvailable = true;
|
||||
installedTheme.availableVersion = downloadable.version;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed versions instead of breaking the whole theme list.
|
||||
@@ -513,7 +575,9 @@ export const reloadThemes = async () => {
|
||||
nsfw: theme.nsfw,
|
||||
definition: theme.definition,
|
||||
updateAvailable: theme.updateAvailable,
|
||||
availableVersion: theme.availableVersion,
|
||||
cssStyle: theme.cssStyle,
|
||||
backups: theme.backups ?? storeTheme.backups,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -555,6 +619,12 @@ export const exportThemeFolder = async (folderName: string, destinationPath: str
|
||||
await invoke('export_theme', { themeFolder, destinationPath });
|
||||
};
|
||||
|
||||
export const listThemeBackups = async (themeFolder: string) =>
|
||||
await invoke<ThemeBackup[]>('list_theme_backups', { themeFolder });
|
||||
|
||||
export const restoreThemeBackup = async (themeFolder: string, backupFolder: string) =>
|
||||
await invoke('restore_theme_backup', { themeFolder, backupFolder });
|
||||
|
||||
export const exportTheme = async (theme: Theme, destinationPath: string) =>
|
||||
exportThemeFolder(theme.folder_name, destinationPath);
|
||||
|
||||
|
||||
@@ -8,6 +8,16 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||
ref?: U | null;
|
||||
};
|
||||
|
||||
export type WithoutChild<T> = T extends { child?: unknown } ? Omit<T, 'child'> : T;
|
||||
|
||||
export type WithoutChildren<T> = T extends { children?: unknown } ? Omit<T, 'children'> : T;
|
||||
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
|
||||
export const numberHumanReadable = (number: number) => {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user