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:
HorizonCode committed 2026-10-29 15:41:33 +01:00
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>
+337
View File
@@ -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">
+13 -7
View File
@@ -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}
+282 -35
View File
@@ -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,
};