@selvajs/ui 6.1.2 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/components/ConfirmDialog.svelte +59 -0
  2. package/dist/components/ConfirmDialog.svelte.d.ts +13 -0
  3. package/dist/components/compute/ComputeApp.svelte +10 -0
  4. package/dist/components/compute/ComputeApp.svelte.d.ts +3 -0
  5. package/dist/components/compute/ParameterPresetManager.svelte +3 -3
  6. package/dist/components/layout/AppShell.svelte +19 -2
  7. package/dist/components/layout/SideNav.svelte +16 -2
  8. package/dist/components/layout/SideNav.svelte.d.ts +5 -0
  9. package/dist/components/preview/inputs/FileInput.svelte +3 -2
  10. package/dist/components/primitives/Callout.svelte +73 -0
  11. package/dist/components/primitives/Callout.svelte.d.ts +40 -0
  12. package/dist/components/primitives/index.d.ts +2 -0
  13. package/dist/components/primitives/index.js +2 -0
  14. package/dist/components/viewer/MeshMetadataDialog.svelte +1 -1
  15. package/dist/components/viewer/SceneManager.svelte +274 -103
  16. package/dist/components/viewer/SceneManager.svelte.d.ts +6 -0
  17. package/dist/components/viewer/Viewer.svelte +104 -32
  18. package/dist/i18n/messages.d.ts +4 -2
  19. package/dist/i18n/messages.js +8 -4
  20. package/package.json +14 -14
  21. package/src/lib/components/ConfirmDialog.svelte +59 -0
  22. package/src/lib/components/compute/ComputeApp.svelte +10 -0
  23. package/src/lib/components/compute/ParameterPresetManager.svelte +3 -3
  24. package/src/lib/components/layout/AppShell.svelte +19 -2
  25. package/src/lib/components/layout/SideNav.svelte +16 -2
  26. package/src/lib/components/preview/inputs/FileInput.svelte +3 -2
  27. package/src/lib/components/primitives/Callout.svelte +73 -0
  28. package/src/lib/components/primitives/index.ts +2 -0
  29. package/src/lib/components/viewer/MeshMetadataDialog.svelte +1 -1
  30. package/src/lib/components/viewer/SceneManager.svelte +274 -103
  31. package/src/lib/components/viewer/Viewer.svelte +104 -32
  32. package/src/lib/i18n/messages.ts +12 -6
@@ -1,10 +1,17 @@
1
1
  <script lang="ts">
2
- import * as THREE from 'three';
3
- import { Eye, EyeOff, ChevronRight, Search, X } from '@lucide/svelte';
4
2
  import {
5
- getObjectLabel,
6
- getTrackingKey,
3
+ Eye,
4
+ EyeOff,
5
+ ChevronRight,
6
+ ChevronsDownUp,
7
+ ChevronsUpDown,
8
+ Search,
9
+ X
10
+ } from '@lucide/svelte';
11
+ import {
12
+ getMemberKeys,
7
13
  getTypeLabel,
14
+ type SceneEntry,
8
15
  type SceneOutliner
9
16
  } from '@selvajs/visualization/scene';
10
17
  import { getLocaleContext } from '../../i18n/localeContext.svelte';
@@ -20,20 +27,46 @@
20
27
  */
21
28
  outliner: SceneOutliner;
22
29
  sceneVersion?: number;
30
+ /**
31
+ * Request a viewer repaint. Toggling `.visible` mutates three objects directly, and the
32
+ * render loop is on-demand — without this the canvas only catches up on its ~500ms idle
33
+ * repaint, so the row updates instantly and the geometry lags behind.
34
+ */
35
+ onVisibilityChange?: () => void;
23
36
  }
24
37
 
25
- let { outliner, sceneVersion = 0 }: Props = $props();
38
+ let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
39
+
40
+ // A partially hidden layer hides the rest; only a fully hidden one comes back — the same rule
41
+ // `visibility.toggleLayer` applies to objects, restated over entries because a layer's rows are
42
+ // now members, not necessarily whole objects.
43
+ const toggleLayer = (entries: SceneEntry[]) => {
44
+ const show = entries.length > 0 && entries.every((entry) => isEntryHidden(entry));
45
+ for (const entry of entries) outliner.visibility.setEntryVisible(entry, show);
46
+ onVisibilityChange?.();
47
+ };
48
+
49
+ const toggleEntry = (entry: SceneEntry) => {
50
+ outliner.toggleEntry(entry);
51
+ onVisibilityChange?.();
52
+ };
26
53
 
27
54
  // Derived, not destructured: the prop is reassignable, and these must follow it.
28
55
  const hidden = $derived(outliner.visibility.hidden);
29
56
  const selected = $derived(outliner.selection.selected);
30
57
  const collapsed = $derived(outliner.collapsed);
31
58
 
32
- // Mirrored into runes because the outliner holds these as plain fields, not sets. Both are panel
33
- // state, so reopening clears them unlike hiding and collapse, which the outliner outlives.
59
+ // Panel state, so reopening clears it unlike hiding and collapse, which the outliner outlives.
60
+ // Owned here rather than on the outliner: writing it back from the derived below is what the
61
+ // Svelte 5 `state_unsafe_mutation` rule forbids.
34
62
  let searchQuery = $state('');
35
63
  let anchor = $state<string | null>(null);
36
64
 
65
+ // Layers start collapsed: a solve can produce dozens, and an all-expanded list buries the
66
+ // layer names it exists to show. Only for the first batch of content — after that the user's
67
+ // collapse state is theirs, and re-collapsing on every solve would fight them.
68
+ let collapsedOnLoad = false;
69
+
37
70
  $effect(() => outliner.onAnchorChange((next) => (anchor = next)));
38
71
 
39
72
  // `scene.children` is a plain array the render layer mutates in place, so a solve bumping
@@ -43,29 +76,135 @@
43
76
  return outliner.objects();
44
77
  });
45
78
 
79
+ // Entries, not objects: a merged mesh renders as one THREE object but lists as one row per
80
+ // source object, so an imported model shows its building elements instead of the handful of
81
+ // meshes they were merged into.
46
82
  const layerGroups = $derived.by(() => {
47
83
  void sceneVersion;
48
- void searchQuery;
49
- outliner.searchQuery = searchQuery;
50
- return outliner.layerGroups();
84
+ return outliner.entryGroups(searchQuery);
85
+ });
86
+
87
+ const collapseAll = () => {
88
+ for (const layerName of outliner.entryGroups().keys()) collapsed.add(layerName);
89
+ };
90
+
91
+ $effect(() => {
92
+ void sceneVersion;
93
+ if (collapsedOnLoad || outliner.entryGroups().size === 0) return;
94
+ collapsedOnLoad = true;
95
+ collapseAll();
96
+ });
97
+
98
+ const expandAll = () => collapsed.clear();
99
+
100
+ const allCollapsed = $derived(
101
+ layerGroups.size > 0 && [...layerGroups.keys()].every((name) => collapsed.has(name))
102
+ );
103
+
104
+ // ============================================================================
105
+ // Windowing
106
+ // ============================================================================
107
+ //
108
+ // A scene can hold thousands of objects. Mounting a row per object makes expanding a layer,
109
+ // typing in the search box, and scrolling all pay for the whole list, so only the rows
110
+ // overlapping the viewport are rendered and the rest are replaced by spacer height.
111
+ //
112
+ // The two row types have different heights, so the flat model records each row's height and a
113
+ // running offset rather than assuming a single row size. Keep these in sync with the markup:
114
+ // a header is `py-1` around a 3.5-unit icon, an object row `py-0.5` around a 3-unit one.
115
+ const LAYER_ROW_HEIGHT = 30;
116
+ const OBJECT_ROW_HEIGHT = 24;
117
+ // Rows rendered beyond each edge of the viewport, so a fast scroll doesn't show blank space.
118
+ const OVERSCAN = 8;
119
+
120
+ type Row =
121
+ | {
122
+ kind: 'layer';
123
+ key: string;
124
+ top: number;
125
+ height: number;
126
+ layerName: string;
127
+ entries: SceneEntry[];
128
+ }
129
+ | { kind: 'object'; key: string; top: number; height: number; entry: SceneEntry };
130
+
131
+ const rows = $derived.by(() => {
132
+ const flat: Row[] = [];
133
+ let top = 0;
134
+ for (const [layerName, entries] of layerGroups) {
135
+ flat.push({
136
+ kind: 'layer',
137
+ key: `layer:${layerName}`,
138
+ top,
139
+ height: LAYER_ROW_HEIGHT,
140
+ layerName,
141
+ entries
142
+ });
143
+ top += LAYER_ROW_HEIGHT;
144
+ if (collapsed.has(layerName)) continue;
145
+ for (const entry of entries) {
146
+ flat.push({
147
+ kind: 'object',
148
+ key: entry.key,
149
+ top,
150
+ height: OBJECT_ROW_HEIGHT,
151
+ entry
152
+ });
153
+ top += OBJECT_ROW_HEIGHT;
154
+ }
155
+ }
156
+ return flat;
157
+ });
158
+
159
+ const totalHeight = $derived(
160
+ rows.length === 0 ? 0 : rows[rows.length - 1]!.top + rows[rows.length - 1]!.height
161
+ );
162
+
163
+ let scrollTop = $state(0);
164
+ let viewportHeight = $state(0);
165
+
166
+ /** First row index whose bottom edge is at or past `offset`. Rows are ordered by `top`. */
167
+ const findRowAt = (offset: number) => {
168
+ let low = 0;
169
+ let high = rows.length - 1;
170
+ while (low < high) {
171
+ const mid = (low + high) >> 1;
172
+ if (rows[mid]!.top + rows[mid]!.height <= offset) low = mid + 1;
173
+ else high = mid;
174
+ }
175
+ return low;
176
+ };
177
+
178
+ const visibleRows = $derived.by(() => {
179
+ if (rows.length === 0) return [];
180
+ // Before the first measurement `viewportHeight` is 0; render a screenful so the list is
181
+ // never briefly empty.
182
+ const height = viewportHeight || 600;
183
+ const start = Math.max(0, findRowAt(scrollTop) - OVERSCAN);
184
+ const end = Math.min(rows.length, findRowAt(scrollTop + height) + 1 + OVERSCAN);
185
+ return rows.slice(start, end);
51
186
  });
52
187
 
53
188
  // `SvelteSet.has()` is the reactive read, so go through the set rather than calling
54
189
  // `visibility.isHidden` — that reaches the set through a plain reference inside the outliner
55
190
  // and returns a correct value that never re-renders this row.
56
- const isObjectHidden = (object: THREE.Object3D) => hidden.has(getTrackingKey(object));
191
+ const isEntryHidden = (entry: SceneEntry) =>
192
+ entry.memberIndex === null
193
+ ? getMemberKeys(entry.object).every((key) => hidden.has(key))
194
+ : hidden.has(entry.key);
57
195
 
58
- // Same reason: count through the reactive set so the layer's tri-state eye tracks its objects.
59
- const hiddenCount = (objects: THREE.Object3D[]) =>
60
- objects.filter((object) => isObjectHidden(object)).length;
196
+ // Same reason: count through the reactive set so the layer's tri-state eye tracks its entries.
197
+ const hiddenCount = (entries: SceneEntry[]) =>
198
+ entries.filter((entry) => isEntryHidden(entry)).length;
61
199
 
62
200
  // Reading `anchor` keeps the shift-range dependent on it; the outliner owns the value.
63
201
  const selectObject = (uuid: string, event: MouseEvent) => {
64
202
  void anchor;
65
- outliner.select(uuid, {
66
- shiftKey: event.shiftKey,
67
- toggleKey: event.ctrlKey || event.metaKey
68
- });
203
+ outliner.select(
204
+ uuid,
205
+ { shiftKey: event.shiftKey, toggleKey: event.ctrlKey || event.metaKey },
206
+ searchQuery
207
+ );
69
208
  };
70
209
  </script>
71
210
 
@@ -88,110 +227,142 @@
88
227
  />
89
228
  </button>
90
229
  {/if}
230
+
231
+ <button
232
+ class="shrink-0"
233
+ onclick={() => (allCollapsed ? expandAll() : collapseAll())}
234
+ title={allCollapsed ? t.expandAll : t.collapseAll}
235
+ aria-label={allCollapsed ? t.expandAll : t.collapseAll}
236
+ >
237
+ {#if allCollapsed}
238
+ <ChevronsUpDown
239
+ class="h-3.5 w-3.5 text-muted-foreground/50 transition-colors hover:text-muted-foreground"
240
+ />
241
+ {:else}
242
+ <ChevronsDownUp
243
+ class="h-3.5 w-3.5 text-muted-foreground/50 transition-colors hover:text-muted-foreground"
244
+ />
245
+ {/if}
246
+ </button>
91
247
  </div>
92
248
 
93
- <div class="py-1 flex-1 overflow-y-auto">
94
- {#each [...layerGroups] as [layerName, objects] (layerName)}
95
- {@const numHidden = hiddenCount(objects)}
96
- {@const layerHidden = objects.length > 0 && numHidden === objects.length}
97
- {@const layerPartial = numHidden > 0 && numHidden < objects.length}
98
- {@const isCollapsed = collapsed.has(layerName)}
99
-
100
- <div class="gap-1 pl-1 pr-2 py-1 group flex items-center transition-colors hover:bg-muted">
101
- <button
102
- class="rounded p-0.5 shrink-0 text-muted-foreground transition-colors hover:text-muted-foreground"
103
- onclick={() => outliner.toggleCollapsed(layerName)}
104
- aria-label={isCollapsed ? t.expandLayer : t.collapseLayer}
105
- >
106
- <ChevronRight
107
- class="h-3.5 w-3.5 transition-transform duration-150 {isCollapsed ? '' : 'rotate-90'}"
108
- />
109
- </button>
110
-
111
- <button
112
- class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
113
- onclick={() => outliner.visibility.toggleLayer(objects)}
114
- title={layerHidden ? t.showLayer : t.hideLayer}
115
- aria-label={layerHidden ? t.showLayer : t.hideLayer}
116
- >
117
- {#if layerHidden}
118
- <EyeOff class="h-3.5 w-3.5 text-muted-foreground/40" />
119
- {:else if layerPartial}
120
- <Eye class="h-3.5 w-3.5 text-muted-foreground/60" />
121
- {:else}
122
- <Eye class="h-3.5 w-3.5 text-muted-foreground" />
123
- {/if}
124
- </button>
125
-
126
- <span
127
- class="min-w-0 text-xs font-medium flex-1 truncate {layerHidden
128
- ? 'text-muted-foreground/40 line-through'
129
- : 'text-foreground'}"
130
- >
131
- {layerName}
132
- </span>
133
-
134
- <span class="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums">
135
- {objects.length}
136
- </span>
137
- </div>
249
+ <div
250
+ class="py-1 flex-1 overflow-y-auto"
251
+ bind:clientHeight={viewportHeight}
252
+ onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)}
253
+ >
254
+ <!-- Spacer sized to the full list; windowed rows are placed into it by offset. The listbox
255
+ sits here rather than per layer: windowing renders one flat row list, so every option
256
+ shares a single owner. -->
257
+ <div role="listbox" aria-multiselectable="true" class="relative" style:height="{totalHeight}px">
258
+ {#each visibleRows as row (row.key)}
259
+ <div class="inset-x-0 absolute" style:top="{row.top}px" style:height="{row.height}px">
260
+ {#if row.kind === 'layer'}
261
+ {@const entries = row.entries}
262
+ {@const numHidden = hiddenCount(entries)}
263
+ {@const layerHidden = entries.length > 0 && numHidden === entries.length}
264
+ {@const layerPartial = numHidden > 0 && numHidden < entries.length}
265
+ {@const isCollapsed = collapsed.has(row.layerName)}
138
266
 
139
- {#if !isCollapsed}
140
- <div role="listbox" aria-multiselectable="true" class="ml-3 border-l border-border">
141
- {#each objects as object (object.uuid)}
142
- <!-- Visibility is keyed by Grasshopper identity (so it survives a solve), selection
143
- by uuid (so it does not) — hence the two different lookups. -->
144
- {@const isHidden = isObjectHidden(object)}
145
- {@const isSelected = selected.has(object.uuid)}
146
267
  <div
147
- role="option"
148
- aria-selected={isSelected}
149
- tabindex="-1"
150
- class="gap-1.5 pl-5 pr-2 py-0.5 flex cursor-pointer items-center transition-colors
151
- {isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
152
- {isHidden ? 'opacity-40' : ''}"
153
- onmousedown={(e) => {
154
- e.stopPropagation();
155
- if (e.shiftKey) e.preventDefault();
156
- }}
157
- onclick={(e) => selectObject(object.uuid, e)}
158
- onkeydown={(e) =>
159
- e.key === 'Enter' && selectObject(object.uuid, e as unknown as MouseEvent)}
268
+ class="gap-1 pl-1 pr-2 group flex h-full items-center transition-colors hover:bg-muted"
160
269
  >
270
+ <button
271
+ class="rounded p-0.5 shrink-0 text-muted-foreground transition-colors hover:text-muted-foreground"
272
+ onclick={() => outliner.toggleCollapsed(row.layerName)}
273
+ aria-label={isCollapsed ? t.expandLayer : t.collapseLayer}
274
+ >
275
+ <ChevronRight
276
+ class="h-3.5 w-3.5 transition-transform duration-150 {isCollapsed
277
+ ? ''
278
+ : 'rotate-90'}"
279
+ />
280
+ </button>
281
+
161
282
  <button
162
283
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
163
- onclick={(e) => {
164
- e.stopPropagation();
165
- outliner.toggleObject(object);
166
- }}
167
- title={isHidden ? t.showObject : t.hideObject}
168
- aria-label={isHidden ? t.showObject : t.hideObject}
284
+ onclick={() => toggleLayer(entries)}
285
+ title={layerHidden ? t.showLayer : t.hideLayer}
286
+ aria-label={layerHidden ? t.showLayer : t.hideLayer}
169
287
  >
170
- {#if isHidden}
171
- <EyeOff class="h-3 w-3 text-muted-foreground/60" />
288
+ {#if layerHidden}
289
+ <EyeOff class="h-3.5 w-3.5 text-muted-foreground/40" />
290
+ {:else if layerPartial}
291
+ <Eye class="h-3.5 w-3.5 text-muted-foreground/60" />
172
292
  {:else}
173
- <Eye class="h-3 w-3 text-muted-foreground" />
293
+ <Eye class="h-3.5 w-3.5 text-muted-foreground" />
174
294
  {/if}
175
295
  </button>
176
296
 
177
297
  <span
178
- class="min-w-0 text-xs flex-1 truncate {isHidden
179
- ? 'text-muted-foreground line-through'
180
- : 'text-foreground/80'}"
298
+ class="min-w-0 text-xs font-medium flex-1 truncate {layerHidden
299
+ ? 'text-muted-foreground/40 line-through'
300
+ : 'text-foreground'}"
181
301
  >
182
- {getObjectLabel(object)}
302
+ {row.layerName}
183
303
  </span>
184
304
 
185
- <span
186
- class="rounded px-1 py-0.5 font-medium shrink-0 bg-muted text-[9px] text-muted-foreground/70"
187
- >
188
- {getTypeLabel(object)}
305
+ <span class="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums">
306
+ {entries.length}
189
307
  </span>
190
308
  </div>
191
- {/each}
309
+ {:else}
310
+ {@const entry = row.entry}
311
+ <!-- Both keyed by the entry's stable identity: a merged mesh holds many entries, so
312
+ the object's uuid cannot tell its members apart. -->
313
+ {@const isHidden = isEntryHidden(entry)}
314
+ {@const isSelected = selected.has(entry.key)}
315
+ <div class="ml-3 h-full border-l border-border">
316
+ <div
317
+ role="option"
318
+ aria-selected={isSelected}
319
+ tabindex="-1"
320
+ class="gap-1.5 pl-5 pr-2 flex h-full cursor-pointer items-center transition-colors
321
+ {isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
322
+ {isHidden ? 'opacity-40' : ''}"
323
+ onmousedown={(e) => {
324
+ e.stopPropagation();
325
+ if (e.shiftKey) e.preventDefault();
326
+ }}
327
+ onclick={(e) => selectObject(entry.key, e)}
328
+ onkeydown={(e) =>
329
+ e.key === 'Enter' && selectObject(entry.key, e as unknown as MouseEvent)}
330
+ >
331
+ <button
332
+ class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
333
+ onclick={(e) => {
334
+ e.stopPropagation();
335
+ toggleEntry(entry);
336
+ }}
337
+ title={isHidden ? t.showObject : t.hideObject}
338
+ aria-label={isHidden ? t.showObject : t.hideObject}
339
+ >
340
+ {#if isHidden}
341
+ <EyeOff class="h-3 w-3 text-muted-foreground/60" />
342
+ {:else}
343
+ <Eye class="h-3 w-3 text-muted-foreground" />
344
+ {/if}
345
+ </button>
346
+
347
+ <span
348
+ class="min-w-0 text-xs flex-1 truncate {isHidden
349
+ ? 'text-muted-foreground line-through'
350
+ : 'text-foreground/80'}"
351
+ >
352
+ {entry.label}
353
+ </span>
354
+
355
+ <span
356
+ class="rounded px-1 py-0.5 font-medium shrink-0 bg-muted text-[9px] text-muted-foreground/70"
357
+ >
358
+ {getTypeLabel(entry.object)}
359
+ </span>
360
+ </div>
361
+ </div>
362
+ {/if}
192
363
  </div>
193
- {/if}
194
- {/each}
364
+ {/each}
365
+ </div>
195
366
 
196
367
  {#if sceneObjects.length === 0}
197
368
  <div class="py-12 flex flex-col items-center justify-center text-center">
@@ -7,6 +7,12 @@ interface Props {
7
7
  */
8
8
  outliner: SceneOutliner;
9
9
  sceneVersion?: number;
10
+ /**
11
+ * Request a viewer repaint. Toggling `.visible` mutates three objects directly, and the
12
+ * render loop is on-demand — without this the canvas only catches up on its ~500ms idle
13
+ * repaint, so the row updates instantly and the geometry lags behind.
14
+ */
15
+ onVisibilityChange?: () => void;
10
16
  }
11
17
  declare const SceneManager: import("svelte").Component<Props, {}, "">;
12
18
  type SceneManager = ReturnType<typeof SceneManager>;