[READ-ONLY] Mirror of https://github.com/flo-bit/shapecraft.
flo-bit.dev/shapecraft/
5.6 kB
165 lines
1<script lang="ts">
2 import { ChevronDown, ChevronRight, RotateCcw, Search } from '@lucide/svelte';
3 import type { OptionDef } from 'shapecraft/core/schema';
4 import type { GeneratorEntry } from '$lib/registry';
5 import { displayValue, getPreset, resetParams, setParam, setPreset } from '$lib/editor.svelte';
6 import SliderControl from './controls/SliderControl.svelte';
7 import ToggleControl from './controls/ToggleControl.svelte';
8 import SelectControl from './controls/SelectControl.svelte';
9 import ColorControl from './controls/ColorControl.svelte';
10 import ColorArrayControl from './controls/ColorArrayControl.svelte';
11
12 let { entry }: { entry: GeneratorEntry } = $props();
13
14 const presetNames = $derived(Object.keys(entry.presets));
15
16 let query = $state('');
17 let collapsed = $state<Record<string, boolean>>({});
18
19 interface Group {
20 name: string;
21 params: [string, OptionDef][];
22 }
23
24 // Groups in order of first appearance in the schema; search filters params
25 // by label/key and overrides collapsing so matches are always visible.
26 const groups = $derived.by(() => {
27 const q = query.trim().toLowerCase();
28 const out: Group[] = [];
29 for (const [key, def] of Object.entries(entry.schema)) {
30 if (q && !(def.label ?? key).toLowerCase().includes(q) && !key.toLowerCase().includes(q))
31 continue;
32 const name = def.group ?? 'General';
33 let group = out.find((g) => g.name === name);
34 if (!group) {
35 group = { name, params: [] };
36 out.push(group);
37 }
38 group.params.push([key, def]);
39 }
40 return out;
41 });
42
43 const searching = $derived(query.trim() !== '');
44
45 function isOpen(name: string) {
46 return searching || !collapsed[`${entry.id}:${name}`];
47 }
48 function toggle(name: string) {
49 if (searching) return;
50 collapsed[`${entry.id}:${name}`] = !collapsed[`${entry.id}:${name}`];
51 }
52</script>
53
54<aside class="flex w-84 shrink-0 flex-col border-l border-line bg-panel">
55 <div class="flex items-center justify-between px-4 pt-[15px] pb-[11px]">
56 <h3 class="text-[15px] font-bold tracking-tight">Parameters</h3>
57 <button
58 type="button"
59 class="grid size-7.5 place-items-center rounded-[9px] border border-line2 bg-panel2 text-muted hover:text-ink"
60 title="Reset to defaults"
61 onclick={() => resetParams(entry.id)}
62 >
63 <RotateCcw size={15} />
64 </button>
65 </div>
66 <div
67 class="mx-3.5 mb-3 flex h-9 items-center gap-2 rounded-[9px] border border-line2 bg-bg px-2.5"
68 >
69 <Search size={15} class="shrink-0 text-faint" />
70 <input
71 type="text"
72 class="w-full border-none bg-transparent p-0 text-[13px] text-ink placeholder:text-faint focus:ring-0"
73 placeholder="Search parameters…"
74 bind:value={query}
75 />
76 </div>
77 {#if presetNames.length > 1}
78 <div class="mx-3.5 mb-3">
79 <select
80 class="h-9.5 w-full rounded-[9px] border-line2 bg-panel2 text-[13px] font-semibold text-ink focus:border-accent focus:ring-0"
81 value={getPreset(entry.id)}
82 onchange={(e) => setPreset(entry.id, e.currentTarget.value)}
83 >
84 {#each presetNames as p (p)}
85 <option value={p}>{p}</option>
86 {/each}
87 </select>
88 </div>
89 {/if}
90 <div class="min-h-0 flex-1 overflow-y-auto px-1.5 pb-3">
91 {#each groups as group, gi (entry.id + ':' + group.name)}
92 <div class={gi > 0 ? 'border-t border-line' : ''}>
93 <button
94 type="button"
95 class="flex w-full items-center gap-2.5 px-2.5 py-3 text-left"
96 onclick={() => toggle(group.name)}
97 >
98 <span class="text-faint">
99 {#if isOpen(group.name)}
100 <ChevronDown size={15} />
101 {:else}
102 <ChevronRight size={15} />
103 {/if}
104 </span>
105 <span class="flex-1 text-[13px] font-bold">{group.name}</span>
106 <span class="mono text-[11px] text-faint">{group.params.length}</span>
107 </button>
108 {#if isOpen(group.name)}
109 <div class="flex flex-col gap-[13px] px-2.5 pt-0.5 pb-3.5">
110 {#each group.params as [key, def] (entry.id + ':' + key)}
111 {#if def.type === 'range'}
112 <SliderControl
113 label={def.label ?? key}
114 value={displayValue(entry, key) as number | [number, number]}
115 min={def.min}
116 max={def.max}
117 step={def.step ?? 0.01}
118 onchange={(v) => setParam(entry.id, key, v)}
119 />
120 {:else if def.type === 'integer'}
121 <SliderControl
122 label={def.label ?? key}
123 value={displayValue(entry, key) as number | [number, number]}
124 min={def.min}
125 max={def.max}
126 step={1}
127 onchange={(v) => setParam(entry.id, key, v)}
128 />
129 {:else if def.type === 'boolean'}
130 <ToggleControl
131 label={def.label ?? key}
132 value={displayValue(entry, key) as boolean}
133 onchange={(v) => setParam(entry.id, key, v)}
134 />
135 {:else if def.type === 'select'}
136 <SelectControl
137 label={def.label ?? key}
138 value={displayValue(entry, key) as string}
139 options={def.options}
140 onchange={(v) => setParam(entry.id, key, v)}
141 />
142 {:else if def.type === 'color'}
143 <ColorControl
144 label={def.label ?? key}
145 value={displayValue(entry, key) as string}
146 onchange={(v) => setParam(entry.id, key, v)}
147 />
148 {:else if def.type === 'color-array'}
149 <ColorArrayControl
150 label={def.label ?? key}
151 colors={displayValue(entry, key) as string[]}
152 min={def.min}
153 max={def.max}
154 onchange={(v) => setParam(entry.id, key, v)}
155 />
156 {/if}
157 {/each}
158 </div>
159 {/if}
160 </div>
161 {:else}
162 <div class="px-3 py-6 text-center text-[13px] text-faint">No matching parameters</div>
163 {/each}
164 </div>
165</aside>