@selvajs/ui 6.6.0-beta.11 → 6.6.0-beta.13
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.
- package/dist/components/compute/AppLayout.svelte +13 -13
- package/dist/components/compute/CollapsedPanelStrip.svelte +4 -2
- package/dist/components/preview/ChartOutput.svelte +56 -23
- package/dist/components/preview/ImageOutput.svelte +5 -2
- package/dist/components/preview/OutputDisplay.svelte +18 -11
- package/dist/components/preview/TabLayout.svelte +1 -1
- package/dist/components/viewer/SceneManager.svelte +12 -1
- package/dist/components/viewer/SceneManager.svelte.d.ts +3 -1
- package/dist/components/viewer/Viewer.svelte +30 -3
- package/package.json +9 -5
- package/src/lib/components/compute/AppLayout.svelte +13 -13
- package/src/lib/components/compute/CollapsedPanelStrip.svelte +4 -2
- package/src/lib/components/preview/ChartOutput.svelte +56 -23
- package/src/lib/components/preview/ImageOutput.svelte +5 -2
- package/src/lib/components/preview/OutputDisplay.svelte +18 -11
- package/src/lib/components/preview/TabLayout.svelte +1 -1
- package/src/lib/components/viewer/SceneManager.svelte +12 -1
- package/src/lib/components/viewer/Viewer.svelte +30 -3
|
@@ -420,7 +420,7 @@
|
|
|
420
420
|
{/if}
|
|
421
421
|
{:else if !hasViewer && !isTwoPanelMode && hasLeftPanel !== hasRightPanel}
|
|
422
422
|
<!-- Single centered panel (left-only or right-only), no viewer -->
|
|
423
|
-
<div class="min-h-0 flex h-full flex-1 justify-center">
|
|
423
|
+
<div class="min-h-0 p-1 flex h-full flex-1 justify-center">
|
|
424
424
|
<div class="min-h-0 flex h-full w-4/5 flex-col">
|
|
425
425
|
{@render panelContent(
|
|
426
426
|
hasRightPanel ? 'right' : undefined,
|
|
@@ -447,7 +447,7 @@
|
|
|
447
447
|
<Resizable.PaneGroup
|
|
448
448
|
direction="horizontal"
|
|
449
449
|
autoSaveId="selva-layout-{layoutKey}"
|
|
450
|
-
class="min-h-0 flex-1"
|
|
450
|
+
class="min-h-0 m-1 flex-1"
|
|
451
451
|
>
|
|
452
452
|
{#if hasLeftPanel}
|
|
453
453
|
<Resizable.Pane
|
|
@@ -460,7 +460,6 @@
|
|
|
460
460
|
collapsedSize={0}
|
|
461
461
|
onCollapse={() => (leftCollapsed = true)}
|
|
462
462
|
onExpand={() => (leftCollapsed = false)}
|
|
463
|
-
class="ml-1"
|
|
464
463
|
>
|
|
465
464
|
<div
|
|
466
465
|
class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
|
|
@@ -481,7 +480,7 @@
|
|
|
481
480
|
{/if}
|
|
482
481
|
|
|
483
482
|
{#if hasViewer}
|
|
484
|
-
<Resizable.Pane order={2} minSize={20} class="min-h-0
|
|
483
|
+
<Resizable.Pane order={2} minSize={20} class="min-h-0 flex flex-col">
|
|
485
484
|
<Viewer
|
|
486
485
|
{meshes}
|
|
487
486
|
bind:isFullscreen={isViewerFullscreen}
|
|
@@ -498,14 +497,16 @@
|
|
|
498
497
|
{/if}
|
|
499
498
|
|
|
500
499
|
{#if hasRightPanel}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
500
|
+
{#if hasViewer || !hasLeftPanel}
|
|
501
|
+
<Resizable.Handle
|
|
502
|
+
withHandle
|
|
503
|
+
line={false}
|
|
504
|
+
data-collapsed={rightCollapsed}
|
|
505
|
+
onclick={() => {
|
|
506
|
+
if (rightCollapsed) rightPaneRef?.expand();
|
|
507
|
+
}}
|
|
508
|
+
/>
|
|
509
|
+
{/if}
|
|
509
510
|
<Resizable.Pane
|
|
510
511
|
bind:this={rightPaneRef}
|
|
511
512
|
order={3}
|
|
@@ -516,7 +517,6 @@
|
|
|
516
517
|
collapsedSize={0}
|
|
517
518
|
onCollapse={() => (rightCollapsed = true)}
|
|
518
519
|
onExpand={() => (rightCollapsed = false)}
|
|
519
|
-
class="mr-1"
|
|
520
520
|
>
|
|
521
521
|
<div
|
|
522
522
|
class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
|
|
@@ -20,12 +20,14 @@
|
|
|
20
20
|
[
|
|
21
21
|
'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
|
|
22
22
|
'flex w-full shrink-0 flex-row items-center',
|
|
23
|
-
'backdrop-blur-sm bg-background/90 cursor-pointer'
|
|
23
|
+
'backdrop-blur-sm bg-background/90 cursor-pointer',
|
|
24
|
+
side === 'left' ? 'lg:border-r' : 'lg:border-l',
|
|
25
|
+
'border-border'
|
|
24
26
|
].join(' ')
|
|
25
27
|
);
|
|
26
28
|
|
|
27
29
|
const tabButtonClass =
|
|
28
|
-
'w-8 h-8 rounded cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
|
|
30
|
+
'w-8 h-8 rounded-md cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
|
|
29
31
|
</script>
|
|
30
32
|
|
|
31
33
|
<div
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { OutputChartLayoutItem } from '@selvajs/schemas';
|
|
3
|
-
import { onMount } from 'svelte';
|
|
3
|
+
import { onMount, tick } from 'svelte';
|
|
4
|
+
import { mode } from 'mode-watcher';
|
|
4
5
|
import { Loader, Maximize, Minimize } from '@lucide/svelte';
|
|
5
6
|
import { loadScript } from '../../utils/loadScript';
|
|
6
7
|
import { getLocaleContext } from '../../i18n/localeContext.svelte';
|
|
@@ -57,6 +58,19 @@
|
|
|
57
58
|
// Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
|
|
58
59
|
const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
|
|
59
60
|
|
|
61
|
+
// `Plots.resize()`/`relayout({autosize: true})` ask Plotly to re-measure the container itself,
|
|
62
|
+
// which it silently fails to do across a Fullscreen transition — _fullLayout keeps the
|
|
63
|
+
// pre-toggle width/height even though clientWidth/clientHeight are already correct. Passing
|
|
64
|
+
// the measured size explicitly is the only call that actually takes.
|
|
65
|
+
function resizeChart() {
|
|
66
|
+
// @ts-expect-error — Plotly is loaded dynamically onto window
|
|
67
|
+
if (!containerEl || !window.Plotly) return;
|
|
68
|
+
const { width, height } = containerEl.getBoundingClientRect();
|
|
69
|
+
if (width === 0 || height === 0) return;
|
|
70
|
+
// @ts-expect-error — Plotly is loaded dynamically onto window
|
|
71
|
+
window.Plotly.relayout(containerEl, { width, height });
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
function toggleFullscreen() {
|
|
61
75
|
if (!wrapperEl) return;
|
|
62
76
|
if (!document.fullscreenElement) {
|
|
@@ -73,26 +87,41 @@
|
|
|
73
87
|
$effect(() => {
|
|
74
88
|
function onFullscreenChange() {
|
|
75
89
|
isFullscreen = !!document.fullscreenElement;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// @ts-expect-error — Plotly is loaded dynamically onto window
|
|
80
|
-
window.Plotly.Plots.resize(containerEl);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
90
|
+
// `tick()` waits for the h-dvh/h-95 class swap this state drives to actually reach the
|
|
91
|
+
// DOM before resizeChart measures it.
|
|
92
|
+
tick().then(resizeChart);
|
|
83
93
|
}
|
|
84
94
|
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
85
95
|
return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
86
96
|
});
|
|
87
97
|
|
|
98
|
+
// Chromium's getComputedStyle returns color values in whatever function the CSS used to
|
|
99
|
+
// declare them — oklch() here, since base.css's theme tokens are oklch(). Plotly's own color
|
|
100
|
+
// parser doesn't recognize oklch() and silently falls back to its #444 default instead of
|
|
101
|
+
// erroring, so every chart read dark axis text even in dark mode. `fillStyle` alone just
|
|
102
|
+
// echoes the oklch() string back unconverted; actually rasterizing a pixel and reading its
|
|
103
|
+
// bytes is what forces a real rgb() value Plotly can parse.
|
|
104
|
+
function toRgb(cssColor: string): string {
|
|
105
|
+
const canvas = document.createElement('canvas');
|
|
106
|
+
canvas.width = 1;
|
|
107
|
+
canvas.height = 1;
|
|
108
|
+
const ctx = canvas.getContext('2d');
|
|
109
|
+
if (!ctx) return cssColor;
|
|
110
|
+
ctx.fillStyle = cssColor;
|
|
111
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
112
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
113
|
+
return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
function getThemeColors(): { textColor: string; gridColor: string } {
|
|
89
117
|
if (!containerEl) return { textColor: '#333', gridColor: '#eee' };
|
|
90
118
|
const style = getComputedStyle(containerEl);
|
|
91
|
-
const textColor = style.getPropertyValue('color').trim() || '#333';
|
|
92
|
-
const gridColor =
|
|
119
|
+
const textColor = toRgb(style.getPropertyValue('color').trim() || '#333');
|
|
120
|
+
const gridColor = toRgb(
|
|
93
121
|
style.getPropertyValue('--border')?.trim() ||
|
|
94
|
-
|
|
95
|
-
|
|
122
|
+
style.getPropertyValue('--color-border')?.trim() ||
|
|
123
|
+
'rgba(128,128,128,0.2)'
|
|
124
|
+
);
|
|
96
125
|
return { textColor, gridColor };
|
|
97
126
|
}
|
|
98
127
|
|
|
@@ -125,11 +154,15 @@
|
|
|
125
154
|
font: { color: textColor },
|
|
126
155
|
paper_bgcolor: layoutRest.paper_bgcolor ?? 'transparent',
|
|
127
156
|
plot_bgcolor: layoutRest.plot_bgcolor ?? 'transparent',
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
157
|
+
// Plotly's axis tick/label color doesn't inherit the top-level `font.color`, so this
|
|
158
|
+
// has to be set even when the figure's own JSON never declared an xaxis/yaxis —
|
|
159
|
+
// which the mock's figures don't. Gating on `layoutRest.xaxis` existing left every
|
|
160
|
+
// axis on Plotly's built-in #444 default, light or dark mode.
|
|
161
|
+
...(!isMapFigure
|
|
162
|
+
? {
|
|
163
|
+
xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor },
|
|
164
|
+
yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor }
|
|
165
|
+
}
|
|
133
166
|
: {})
|
|
134
167
|
};
|
|
135
168
|
|
|
@@ -154,11 +187,7 @@
|
|
|
154
187
|
const ro = new ResizeObserver(() => {
|
|
155
188
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
156
189
|
resizeTimer = setTimeout(() => {
|
|
157
|
-
|
|
158
|
-
if (containerEl && window.Plotly) {
|
|
159
|
-
// @ts-expect-error — Plotly is loaded dynamically onto window
|
|
160
|
-
window.Plotly.Plots.resize(containerEl);
|
|
161
|
-
}
|
|
190
|
+
resizeChart();
|
|
162
191
|
resizeTimer = null;
|
|
163
192
|
}, 150);
|
|
164
193
|
});
|
|
@@ -175,6 +204,10 @@
|
|
|
175
204
|
let renderTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
176
205
|
|
|
177
206
|
$effect(() => {
|
|
207
|
+
// Re-reads mode.current so a theme toggle re-renders with the new axis/font colors —
|
|
208
|
+
// getThemeColors() only reads the DOM once, at render time, and nothing else repaints an
|
|
209
|
+
// already-drawn chart when the page's color scheme changes under it.
|
|
210
|
+
void mode.current;
|
|
178
211
|
if (!value || !mounted || !containerEl) return;
|
|
179
212
|
if (renderTimeout) clearTimeout(renderTimeout);
|
|
180
213
|
renderTimeout = setTimeout(() => {
|
|
@@ -189,7 +222,7 @@
|
|
|
189
222
|
|
|
190
223
|
<div
|
|
191
224
|
bind:this={wrapperEl}
|
|
192
|
-
class="group
|
|
225
|
+
class="group relative overflow-hidden rounded-lg border border-border {isFullscreen
|
|
193
226
|
? 'bg-background'
|
|
194
227
|
: ''}"
|
|
195
228
|
>
|
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
// Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
|
|
66
66
|
const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
|
|
67
67
|
const allowFullscreen = $derived((cfg.allowFullscreen ?? true) && fullscreenSupported);
|
|
68
|
-
|
|
68
|
+
// SVGs are line drawings (often black on transparent), so a checkerboard hurts legibility.
|
|
69
|
+
const backgroundColor = $derived(cfg.backgroundColor ?? (isSvg ? '#ffffff' : undefined));
|
|
69
70
|
|
|
70
71
|
let wrapperEl = $state<HTMLDivElement | null>(null);
|
|
71
72
|
let stageEl = $state<HTMLDivElement | null>(null);
|
|
@@ -303,7 +304,7 @@
|
|
|
303
304
|
{#if error || unsupported || current}
|
|
304
305
|
<div
|
|
305
306
|
bind:this={wrapperEl}
|
|
306
|
-
class="
|
|
307
|
+
class="flex flex-col overflow-hidden rounded-lg border border-border bg-background {isFullscreen
|
|
307
308
|
? 'h-dvh'
|
|
308
309
|
: ''}"
|
|
309
310
|
>
|
|
@@ -319,6 +320,7 @@
|
|
|
319
320
|
{:else if current}
|
|
320
321
|
<!-- The stage is a pan/zoom surface with its own keyboard shortcuts; no native element fits. -->
|
|
321
322
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
323
|
+
<!-- eslint-disable shadcn/no-inline-styles -- backgroundColor is the author's per-output color, not a theme token -->
|
|
322
324
|
<div
|
|
323
325
|
bind:this={stageEl}
|
|
324
326
|
role="application"
|
|
@@ -338,6 +340,7 @@
|
|
|
338
340
|
ondblclick={onDoubleClick}
|
|
339
341
|
onkeydown={onKeyDown}
|
|
340
342
|
>
|
|
343
|
+
<!-- eslint-enable shadcn/no-inline-styles -->
|
|
341
344
|
{#if decodeFailed}
|
|
342
345
|
<div class="inset-0 text-sm absolute flex items-center justify-center text-destructive">
|
|
343
346
|
{formatMessage(t.imageDecodeFailed, { file: `${current.fileName}${current.fileType}` })}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
);
|
|
39
39
|
const hasValue = $derived(value != null);
|
|
40
40
|
|
|
41
|
-
const boxClass = 'min-h-12.5 rounded px-3 py-3
|
|
41
|
+
const boxClass = 'min-h-12.5 rounded-md px-3 py-3 text-sm border border-border/60';
|
|
42
42
|
|
|
43
43
|
async function copyToClipboard() {
|
|
44
44
|
try {
|
|
@@ -157,7 +157,11 @@
|
|
|
157
157
|
|
|
158
158
|
{#snippet fieldHeader()}
|
|
159
159
|
<div class="gap-2 flex items-center">
|
|
160
|
-
<Label>
|
|
160
|
+
<Label>
|
|
161
|
+
<span class={item.widgetType === 'number' ? 'text-xs font-normal text-muted-foreground' : ''}
|
|
162
|
+
>{label}</span
|
|
163
|
+
>
|
|
164
|
+
</Label>
|
|
161
165
|
{#if item.description}
|
|
162
166
|
<HelpButton {label} description={item.description} />
|
|
163
167
|
{/if}
|
|
@@ -213,12 +217,12 @@
|
|
|
213
217
|
|
|
214
218
|
{#snippet fileDisplay()}
|
|
215
219
|
{#if fileCount === 0}
|
|
216
|
-
<div class="
|
|
220
|
+
<div class="px-3 py-3 text-sm rounded-md border border-border bg-muted text-muted-foreground">
|
|
217
221
|
{t.waitingForFiles}
|
|
218
222
|
</div>
|
|
219
223
|
{:else}
|
|
220
224
|
<div class="gap-2 flex flex-col">
|
|
221
|
-
<div class="
|
|
225
|
+
<div class="px-3 py-2 rounded-md border border-border bg-muted">
|
|
222
226
|
<div class="gap-2 flex items-center justify-between">
|
|
223
227
|
<span class="min-w-0 text-sm font-medium truncate text-foreground">
|
|
224
228
|
{fileCount === 1
|
|
@@ -248,7 +252,7 @@
|
|
|
248
252
|
</div>
|
|
249
253
|
{#if hasDuplicates}
|
|
250
254
|
<div
|
|
251
|
-
class="
|
|
255
|
+
class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
|
|
252
256
|
>
|
|
253
257
|
<div class="font-medium">{t.duplicateFilesTitle}</div>
|
|
254
258
|
<div class="mt-1 text-xs">
|
|
@@ -264,7 +268,7 @@
|
|
|
264
268
|
{/if}
|
|
265
269
|
{#if downloadError}
|
|
266
270
|
<div
|
|
267
|
-
class="
|
|
271
|
+
class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
|
|
268
272
|
>
|
|
269
273
|
{downloadError}
|
|
270
274
|
</div>
|
|
@@ -282,7 +286,10 @@
|
|
|
282
286
|
|
|
283
287
|
<!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
|
|
284
288
|
{#if item.widgetType !== 'dynamicValueList'}
|
|
285
|
-
<div
|
|
289
|
+
<div
|
|
290
|
+
role="group"
|
|
291
|
+
class="group/field flex flex-col {item.widgetType === 'number' ? 'gap-0.5' : 'gap-2'}"
|
|
292
|
+
>
|
|
286
293
|
{@render fieldHeader()}
|
|
287
294
|
|
|
288
295
|
{#if item.widgetType === 'chart'}
|
|
@@ -295,18 +302,18 @@
|
|
|
295
302
|
{:else if item.widgetType === 'file'}
|
|
296
303
|
{@render fileDisplay()}
|
|
297
304
|
{:else if item.widgetType === 'number'}
|
|
298
|
-
<div class="
|
|
305
|
+
<div class="min-h-8 text-2xl font-semibold wrap-break-word text-foreground tabular-nums">
|
|
299
306
|
{#if hasValue}
|
|
300
|
-
|
|
307
|
+
{formattedValue}
|
|
301
308
|
{:else}
|
|
302
|
-
{@render placeholder()}
|
|
309
|
+
<span class="text-base font-normal">{@render placeholder()}</span>
|
|
303
310
|
{/if}
|
|
304
311
|
</div>
|
|
305
312
|
{:else if item.widgetType === 'text'}
|
|
306
313
|
<div class="group relative">
|
|
307
314
|
{#if isObjectValue}
|
|
308
315
|
<pre
|
|
309
|
-
class="{boxClass} max-h-96 overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
|
|
316
|
+
class="{boxClass} max-h-96 font-mono overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
|
|
310
317
|
{:else}
|
|
311
318
|
<div
|
|
312
319
|
class="{boxClass} bg-muted/10 wrap-anywhere wrap-break-word whitespace-pre-wrap text-foreground"
|
|
@@ -33,9 +33,11 @@
|
|
|
33
33
|
* repaint, so the row updates instantly and the geometry lags behind.
|
|
34
34
|
*/
|
|
35
35
|
onVisibilityChange?: () => void;
|
|
36
|
+
/** The entry under the pointer, or null once it leaves. Drives the viewport preview tint. */
|
|
37
|
+
onHover?: (entry: SceneEntry | null) => void;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
|
|
40
|
+
let { outliner, sceneVersion = 0, onVisibilityChange, onHover }: Props = $props();
|
|
39
41
|
|
|
40
42
|
// A partially hidden layer hides the rest; only a fully hidden one comes back — the same rule
|
|
41
43
|
// `visibility.toggleLayer` applies to objects, restated over entries because a layer's rows are
|
|
@@ -51,6 +53,13 @@
|
|
|
51
53
|
onVisibilityChange?.();
|
|
52
54
|
};
|
|
53
55
|
|
|
56
|
+
// A solve or closing the panel removes the row without a mouseleave, which would leave the
|
|
57
|
+
// preview tint stuck on the viewport.
|
|
58
|
+
$effect(() => {
|
|
59
|
+
void sceneVersion;
|
|
60
|
+
return () => onHover?.(null);
|
|
61
|
+
});
|
|
62
|
+
|
|
54
63
|
// Derived, not destructured: the prop is reassignable, and these must follow it.
|
|
55
64
|
const hidden = $derived(outliner.visibility.hidden);
|
|
56
65
|
const selected = $derived(outliner.selection.selected);
|
|
@@ -331,6 +340,8 @@
|
|
|
331
340
|
class="gap-1.5 pl-5 pr-2 flex h-full cursor-pointer items-center transition-colors
|
|
332
341
|
{isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
|
|
333
342
|
{isHidden ? 'opacity-40' : ''}"
|
|
343
|
+
onmouseenter={() => onHover?.(entry)}
|
|
344
|
+
onmouseleave={() => onHover?.(null)}
|
|
334
345
|
onmousedown={(e) => {
|
|
335
346
|
e.stopPropagation();
|
|
336
347
|
if (e.shiftKey) e.preventDefault();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type SceneOutliner } from '@selvajs/visualization/scene';
|
|
1
|
+
import { type SceneEntry, type SceneOutliner } from '@selvajs/visualization/scene';
|
|
2
2
|
interface Props {
|
|
3
3
|
/**
|
|
4
4
|
* Owned by `Viewer.svelte`, never built here: this panel unmounts when it closes, and hidden
|
|
@@ -13,6 +13,8 @@ interface Props {
|
|
|
13
13
|
* repaint, so the row updates instantly and the geometry lags behind.
|
|
14
14
|
*/
|
|
15
15
|
onVisibilityChange?: () => void;
|
|
16
|
+
/** The entry under the pointer, or null once it leaves. Drives the viewport preview tint. */
|
|
17
|
+
onHover?: (entry: SceneEntry | null) => void;
|
|
16
18
|
}
|
|
17
19
|
declare const SceneManager: import("svelte").Component<Props, {}, "">;
|
|
18
20
|
type SceneManager = ReturnType<typeof SceneManager>;
|
|
@@ -129,6 +129,8 @@
|
|
|
129
129
|
let updateShadowBounds: (() => void) | null = null;
|
|
130
130
|
let fitToView: (() => void) | null = null;
|
|
131
131
|
let clearSelection: (() => void) | null = null;
|
|
132
|
+
let selectEntries: ThreeViewer['selectEntries'] | null = null;
|
|
133
|
+
let setHover: ThreeViewer['setHover'] | null = null;
|
|
132
134
|
let viewerInitialized = false;
|
|
133
135
|
let sceneVersion = $state(0);
|
|
134
136
|
let hideButton = $state(false);
|
|
@@ -203,8 +205,8 @@
|
|
|
203
205
|
events: {
|
|
204
206
|
// Fires before onMeshMetadataClicked, so picking an object without metadata closes the
|
|
205
207
|
// previous object's panel instead of leaving it describing the wrong thing.
|
|
206
|
-
onObjectSelected:
|
|
207
|
-
onBackgroundClicked:
|
|
208
|
+
onObjectSelected: dropPanelSelection,
|
|
209
|
+
onBackgroundClicked: dropPanelSelection,
|
|
208
210
|
onMeshMetadataClicked: config.enableMeshClick
|
|
209
211
|
? (metadata: Record<string, unknown>) => {
|
|
210
212
|
if (hasUsefulMetadata(metadata)) {
|
|
@@ -236,6 +238,8 @@
|
|
|
236
238
|
updateShadowBounds = init.updateShadowBounds;
|
|
237
239
|
fitToView = init.fitToView;
|
|
238
240
|
clearSelection = init.clearSelection;
|
|
241
|
+
selectEntries = init.selectEntries;
|
|
242
|
+
setHover = init.setHover;
|
|
239
243
|
projection = init.cameraController.getProjection();
|
|
240
244
|
|
|
241
245
|
// Untracked so the host reading `meshes` or config inside its setup can't re-run onMount's teardown.
|
|
@@ -389,6 +393,24 @@
|
|
|
389
393
|
selectedMeshName = null;
|
|
390
394
|
}
|
|
391
395
|
|
|
396
|
+
// A click on the canvas replaces whatever the panel selected: one selection at a time.
|
|
397
|
+
function dropPanelSelection() {
|
|
398
|
+
closeMetadata();
|
|
399
|
+
selectedObjects.clear();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Mirrors the panel's selection into the viewport. The size check skips walking the entries for
|
|
403
|
+
// the common empty case, which also keeps `has()` from subscribing to every key.
|
|
404
|
+
$effect(() => {
|
|
405
|
+
if (selectedObjects.size === 0) {
|
|
406
|
+
selectEntries?.([]);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const chosen = outliner?.entries().filter((entry) => selectedObjects.has(entry.key)) ?? [];
|
|
410
|
+
closeMetadata();
|
|
411
|
+
selectEntries?.(chosen);
|
|
412
|
+
});
|
|
413
|
+
|
|
392
414
|
function dismissMetadata() {
|
|
393
415
|
closeMetadata();
|
|
394
416
|
clearSelection?.();
|
|
@@ -655,7 +677,12 @@
|
|
|
655
677
|
{#if sceneManagerOpen && scene && outliner}
|
|
656
678
|
<Resizable.Handle withHandle />
|
|
657
679
|
<Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
|
|
658
|
-
<SceneManager
|
|
680
|
+
<SceneManager
|
|
681
|
+
{outliner}
|
|
682
|
+
{sceneVersion}
|
|
683
|
+
onVisibilityChange={() => invalidate?.()}
|
|
684
|
+
onHover={(entry) => setHover?.(entry?.object ?? null, entry?.memberIndex ?? null)}
|
|
685
|
+
/>
|
|
659
686
|
</Resizable.Pane>
|
|
660
687
|
{/if}
|
|
661
688
|
</Resizable.PaneGroup>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "6.6.0-beta.
|
|
3
|
+
"version": "6.6.0-beta.13",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "VektorNode",
|
|
@@ -31,12 +31,16 @@
|
|
|
31
31
|
".": {
|
|
32
32
|
"selva-source": {
|
|
33
33
|
"types": "./dist/index.d.ts",
|
|
34
|
-
"svelte": "./
|
|
34
|
+
"svelte": "./src/lib/index.ts"
|
|
35
35
|
},
|
|
36
36
|
"types": "./dist/public.d.ts",
|
|
37
37
|
"svelte": "./dist/public.js"
|
|
38
38
|
},
|
|
39
39
|
"./external": {
|
|
40
|
+
"selva-source": {
|
|
41
|
+
"types": "./dist/external/storage.d.ts",
|
|
42
|
+
"svelte": "./src/lib/external/storage.ts"
|
|
43
|
+
},
|
|
40
44
|
"types": "./dist/external/storage.d.ts",
|
|
41
45
|
"svelte": "./dist/external/storage.js"
|
|
42
46
|
},
|
|
@@ -61,8 +65,8 @@
|
|
|
61
65
|
"three": "^0.186.1",
|
|
62
66
|
"@selvajs/compute": "^5.0.0-beta.8",
|
|
63
67
|
"@selvajs/schemas": "^5.3.0-beta.1",
|
|
64
|
-
"@selvajs/
|
|
65
|
-
"@selvajs/
|
|
68
|
+
"@selvajs/visualization": "^1.6.0-beta.7",
|
|
69
|
+
"@selvajs/solve": "^2.0.0-beta.10"
|
|
66
70
|
},
|
|
67
71
|
"peerDependenciesMeta": {
|
|
68
72
|
"three": {
|
|
@@ -92,8 +96,8 @@
|
|
|
92
96
|
"vitest": "^5.0.5",
|
|
93
97
|
"@selvajs/compute": "5.0.0-beta.8",
|
|
94
98
|
"@selvajs/config": "0.0.4",
|
|
95
|
-
"@selvajs/visualization": "1.6.0-beta.5",
|
|
96
99
|
"@selvajs/schemas": "5.3.0-beta.1",
|
|
100
|
+
"@selvajs/visualization": "1.6.0-beta.7",
|
|
97
101
|
"@selvajs/solve": "2.0.0-beta.10"
|
|
98
102
|
},
|
|
99
103
|
"scripts": {
|
|
@@ -420,7 +420,7 @@
|
|
|
420
420
|
{/if}
|
|
421
421
|
{:else if !hasViewer && !isTwoPanelMode && hasLeftPanel !== hasRightPanel}
|
|
422
422
|
<!-- Single centered panel (left-only or right-only), no viewer -->
|
|
423
|
-
<div class="min-h-0 flex h-full flex-1 justify-center">
|
|
423
|
+
<div class="min-h-0 p-1 flex h-full flex-1 justify-center">
|
|
424
424
|
<div class="min-h-0 flex h-full w-4/5 flex-col">
|
|
425
425
|
{@render panelContent(
|
|
426
426
|
hasRightPanel ? 'right' : undefined,
|
|
@@ -447,7 +447,7 @@
|
|
|
447
447
|
<Resizable.PaneGroup
|
|
448
448
|
direction="horizontal"
|
|
449
449
|
autoSaveId="selva-layout-{layoutKey}"
|
|
450
|
-
class="min-h-0 flex-1"
|
|
450
|
+
class="min-h-0 m-1 flex-1"
|
|
451
451
|
>
|
|
452
452
|
{#if hasLeftPanel}
|
|
453
453
|
<Resizable.Pane
|
|
@@ -460,7 +460,6 @@
|
|
|
460
460
|
collapsedSize={0}
|
|
461
461
|
onCollapse={() => (leftCollapsed = true)}
|
|
462
462
|
onExpand={() => (leftCollapsed = false)}
|
|
463
|
-
class="ml-1"
|
|
464
463
|
>
|
|
465
464
|
<div
|
|
466
465
|
class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
|
|
@@ -481,7 +480,7 @@
|
|
|
481
480
|
{/if}
|
|
482
481
|
|
|
483
482
|
{#if hasViewer}
|
|
484
|
-
<Resizable.Pane order={2} minSize={20} class="min-h-0
|
|
483
|
+
<Resizable.Pane order={2} minSize={20} class="min-h-0 flex flex-col">
|
|
485
484
|
<Viewer
|
|
486
485
|
{meshes}
|
|
487
486
|
bind:isFullscreen={isViewerFullscreen}
|
|
@@ -498,14 +497,16 @@
|
|
|
498
497
|
{/if}
|
|
499
498
|
|
|
500
499
|
{#if hasRightPanel}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
500
|
+
{#if hasViewer || !hasLeftPanel}
|
|
501
|
+
<Resizable.Handle
|
|
502
|
+
withHandle
|
|
503
|
+
line={false}
|
|
504
|
+
data-collapsed={rightCollapsed}
|
|
505
|
+
onclick={() => {
|
|
506
|
+
if (rightCollapsed) rightPaneRef?.expand();
|
|
507
|
+
}}
|
|
508
|
+
/>
|
|
509
|
+
{/if}
|
|
509
510
|
<Resizable.Pane
|
|
510
511
|
bind:this={rightPaneRef}
|
|
511
512
|
order={3}
|
|
@@ -516,7 +517,6 @@
|
|
|
516
517
|
collapsedSize={0}
|
|
517
518
|
onCollapse={() => (rightCollapsed = true)}
|
|
518
519
|
onExpand={() => (rightCollapsed = false)}
|
|
519
|
-
class="mr-1"
|
|
520
520
|
>
|
|
521
521
|
<div
|
|
522
522
|
class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
|
|
@@ -20,12 +20,14 @@
|
|
|
20
20
|
[
|
|
21
21
|
'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
|
|
22
22
|
'flex w-full shrink-0 flex-row items-center',
|
|
23
|
-
'backdrop-blur-sm bg-background/90 cursor-pointer'
|
|
23
|
+
'backdrop-blur-sm bg-background/90 cursor-pointer',
|
|
24
|
+
side === 'left' ? 'lg:border-r' : 'lg:border-l',
|
|
25
|
+
'border-border'
|
|
24
26
|
].join(' ')
|
|
25
27
|
);
|
|
26
28
|
|
|
27
29
|
const tabButtonClass =
|
|
28
|
-
'w-8 h-8 rounded cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
|
|
30
|
+
'w-8 h-8 rounded-md cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
|
|
29
31
|
</script>
|
|
30
32
|
|
|
31
33
|
<div
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { OutputChartLayoutItem } from '@selvajs/schemas';
|
|
3
|
-
import { onMount } from 'svelte';
|
|
3
|
+
import { onMount, tick } from 'svelte';
|
|
4
|
+
import { mode } from 'mode-watcher';
|
|
4
5
|
import { Loader, Maximize, Minimize } from '@lucide/svelte';
|
|
5
6
|
import { loadScript } from '$lib/utils/loadScript';
|
|
6
7
|
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
@@ -57,6 +58,19 @@
|
|
|
57
58
|
// Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
|
|
58
59
|
const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
|
|
59
60
|
|
|
61
|
+
// `Plots.resize()`/`relayout({autosize: true})` ask Plotly to re-measure the container itself,
|
|
62
|
+
// which it silently fails to do across a Fullscreen transition — _fullLayout keeps the
|
|
63
|
+
// pre-toggle width/height even though clientWidth/clientHeight are already correct. Passing
|
|
64
|
+
// the measured size explicitly is the only call that actually takes.
|
|
65
|
+
function resizeChart() {
|
|
66
|
+
// @ts-expect-error — Plotly is loaded dynamically onto window
|
|
67
|
+
if (!containerEl || !window.Plotly) return;
|
|
68
|
+
const { width, height } = containerEl.getBoundingClientRect();
|
|
69
|
+
if (width === 0 || height === 0) return;
|
|
70
|
+
// @ts-expect-error — Plotly is loaded dynamically onto window
|
|
71
|
+
window.Plotly.relayout(containerEl, { width, height });
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
function toggleFullscreen() {
|
|
61
75
|
if (!wrapperEl) return;
|
|
62
76
|
if (!document.fullscreenElement) {
|
|
@@ -73,26 +87,41 @@
|
|
|
73
87
|
$effect(() => {
|
|
74
88
|
function onFullscreenChange() {
|
|
75
89
|
isFullscreen = !!document.fullscreenElement;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// @ts-expect-error — Plotly is loaded dynamically onto window
|
|
80
|
-
window.Plotly.Plots.resize(containerEl);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
90
|
+
// `tick()` waits for the h-dvh/h-95 class swap this state drives to actually reach the
|
|
91
|
+
// DOM before resizeChart measures it.
|
|
92
|
+
tick().then(resizeChart);
|
|
83
93
|
}
|
|
84
94
|
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
85
95
|
return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
86
96
|
});
|
|
87
97
|
|
|
98
|
+
// Chromium's getComputedStyle returns color values in whatever function the CSS used to
|
|
99
|
+
// declare them — oklch() here, since base.css's theme tokens are oklch(). Plotly's own color
|
|
100
|
+
// parser doesn't recognize oklch() and silently falls back to its #444 default instead of
|
|
101
|
+
// erroring, so every chart read dark axis text even in dark mode. `fillStyle` alone just
|
|
102
|
+
// echoes the oklch() string back unconverted; actually rasterizing a pixel and reading its
|
|
103
|
+
// bytes is what forces a real rgb() value Plotly can parse.
|
|
104
|
+
function toRgb(cssColor: string): string {
|
|
105
|
+
const canvas = document.createElement('canvas');
|
|
106
|
+
canvas.width = 1;
|
|
107
|
+
canvas.height = 1;
|
|
108
|
+
const ctx = canvas.getContext('2d');
|
|
109
|
+
if (!ctx) return cssColor;
|
|
110
|
+
ctx.fillStyle = cssColor;
|
|
111
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
112
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
113
|
+
return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
function getThemeColors(): { textColor: string; gridColor: string } {
|
|
89
117
|
if (!containerEl) return { textColor: '#333', gridColor: '#eee' };
|
|
90
118
|
const style = getComputedStyle(containerEl);
|
|
91
|
-
const textColor = style.getPropertyValue('color').trim() || '#333';
|
|
92
|
-
const gridColor =
|
|
119
|
+
const textColor = toRgb(style.getPropertyValue('color').trim() || '#333');
|
|
120
|
+
const gridColor = toRgb(
|
|
93
121
|
style.getPropertyValue('--border')?.trim() ||
|
|
94
|
-
|
|
95
|
-
|
|
122
|
+
style.getPropertyValue('--color-border')?.trim() ||
|
|
123
|
+
'rgba(128,128,128,0.2)'
|
|
124
|
+
);
|
|
96
125
|
return { textColor, gridColor };
|
|
97
126
|
}
|
|
98
127
|
|
|
@@ -125,11 +154,15 @@
|
|
|
125
154
|
font: { color: textColor },
|
|
126
155
|
paper_bgcolor: layoutRest.paper_bgcolor ?? 'transparent',
|
|
127
156
|
plot_bgcolor: layoutRest.plot_bgcolor ?? 'transparent',
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
157
|
+
// Plotly's axis tick/label color doesn't inherit the top-level `font.color`, so this
|
|
158
|
+
// has to be set even when the figure's own JSON never declared an xaxis/yaxis —
|
|
159
|
+
// which the mock's figures don't. Gating on `layoutRest.xaxis` existing left every
|
|
160
|
+
// axis on Plotly's built-in #444 default, light or dark mode.
|
|
161
|
+
...(!isMapFigure
|
|
162
|
+
? {
|
|
163
|
+
xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor },
|
|
164
|
+
yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor }
|
|
165
|
+
}
|
|
133
166
|
: {})
|
|
134
167
|
};
|
|
135
168
|
|
|
@@ -154,11 +187,7 @@
|
|
|
154
187
|
const ro = new ResizeObserver(() => {
|
|
155
188
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
156
189
|
resizeTimer = setTimeout(() => {
|
|
157
|
-
|
|
158
|
-
if (containerEl && window.Plotly) {
|
|
159
|
-
// @ts-expect-error — Plotly is loaded dynamically onto window
|
|
160
|
-
window.Plotly.Plots.resize(containerEl);
|
|
161
|
-
}
|
|
190
|
+
resizeChart();
|
|
162
191
|
resizeTimer = null;
|
|
163
192
|
}, 150);
|
|
164
193
|
});
|
|
@@ -175,6 +204,10 @@
|
|
|
175
204
|
let renderTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
176
205
|
|
|
177
206
|
$effect(() => {
|
|
207
|
+
// Re-reads mode.current so a theme toggle re-renders with the new axis/font colors —
|
|
208
|
+
// getThemeColors() only reads the DOM once, at render time, and nothing else repaints an
|
|
209
|
+
// already-drawn chart when the page's color scheme changes under it.
|
|
210
|
+
void mode.current;
|
|
178
211
|
if (!value || !mounted || !containerEl) return;
|
|
179
212
|
if (renderTimeout) clearTimeout(renderTimeout);
|
|
180
213
|
renderTimeout = setTimeout(() => {
|
|
@@ -189,7 +222,7 @@
|
|
|
189
222
|
|
|
190
223
|
<div
|
|
191
224
|
bind:this={wrapperEl}
|
|
192
|
-
class="group
|
|
225
|
+
class="group relative overflow-hidden rounded-lg border border-border {isFullscreen
|
|
193
226
|
? 'bg-background'
|
|
194
227
|
: ''}"
|
|
195
228
|
>
|
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
// Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
|
|
66
66
|
const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
|
|
67
67
|
const allowFullscreen = $derived((cfg.allowFullscreen ?? true) && fullscreenSupported);
|
|
68
|
-
|
|
68
|
+
// SVGs are line drawings (often black on transparent), so a checkerboard hurts legibility.
|
|
69
|
+
const backgroundColor = $derived(cfg.backgroundColor ?? (isSvg ? '#ffffff' : undefined));
|
|
69
70
|
|
|
70
71
|
let wrapperEl = $state<HTMLDivElement | null>(null);
|
|
71
72
|
let stageEl = $state<HTMLDivElement | null>(null);
|
|
@@ -303,7 +304,7 @@
|
|
|
303
304
|
{#if error || unsupported || current}
|
|
304
305
|
<div
|
|
305
306
|
bind:this={wrapperEl}
|
|
306
|
-
class="
|
|
307
|
+
class="flex flex-col overflow-hidden rounded-lg border border-border bg-background {isFullscreen
|
|
307
308
|
? 'h-dvh'
|
|
308
309
|
: ''}"
|
|
309
310
|
>
|
|
@@ -319,6 +320,7 @@
|
|
|
319
320
|
{:else if current}
|
|
320
321
|
<!-- The stage is a pan/zoom surface with its own keyboard shortcuts; no native element fits. -->
|
|
321
322
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
323
|
+
<!-- eslint-disable shadcn/no-inline-styles -- backgroundColor is the author's per-output color, not a theme token -->
|
|
322
324
|
<div
|
|
323
325
|
bind:this={stageEl}
|
|
324
326
|
role="application"
|
|
@@ -338,6 +340,7 @@
|
|
|
338
340
|
ondblclick={onDoubleClick}
|
|
339
341
|
onkeydown={onKeyDown}
|
|
340
342
|
>
|
|
343
|
+
<!-- eslint-enable shadcn/no-inline-styles -->
|
|
341
344
|
{#if decodeFailed}
|
|
342
345
|
<div class="inset-0 text-sm absolute flex items-center justify-center text-destructive">
|
|
343
346
|
{formatMessage(t.imageDecodeFailed, { file: `${current.fileName}${current.fileType}` })}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
);
|
|
39
39
|
const hasValue = $derived(value != null);
|
|
40
40
|
|
|
41
|
-
const boxClass = 'min-h-12.5 rounded px-3 py-3
|
|
41
|
+
const boxClass = 'min-h-12.5 rounded-md px-3 py-3 text-sm border border-border/60';
|
|
42
42
|
|
|
43
43
|
async function copyToClipboard() {
|
|
44
44
|
try {
|
|
@@ -157,7 +157,11 @@
|
|
|
157
157
|
|
|
158
158
|
{#snippet fieldHeader()}
|
|
159
159
|
<div class="gap-2 flex items-center">
|
|
160
|
-
<Label>
|
|
160
|
+
<Label>
|
|
161
|
+
<span class={item.widgetType === 'number' ? 'text-xs font-normal text-muted-foreground' : ''}
|
|
162
|
+
>{label}</span
|
|
163
|
+
>
|
|
164
|
+
</Label>
|
|
161
165
|
{#if item.description}
|
|
162
166
|
<HelpButton {label} description={item.description} />
|
|
163
167
|
{/if}
|
|
@@ -213,12 +217,12 @@
|
|
|
213
217
|
|
|
214
218
|
{#snippet fileDisplay()}
|
|
215
219
|
{#if fileCount === 0}
|
|
216
|
-
<div class="
|
|
220
|
+
<div class="px-3 py-3 text-sm rounded-md border border-border bg-muted text-muted-foreground">
|
|
217
221
|
{t.waitingForFiles}
|
|
218
222
|
</div>
|
|
219
223
|
{:else}
|
|
220
224
|
<div class="gap-2 flex flex-col">
|
|
221
|
-
<div class="
|
|
225
|
+
<div class="px-3 py-2 rounded-md border border-border bg-muted">
|
|
222
226
|
<div class="gap-2 flex items-center justify-between">
|
|
223
227
|
<span class="min-w-0 text-sm font-medium truncate text-foreground">
|
|
224
228
|
{fileCount === 1
|
|
@@ -248,7 +252,7 @@
|
|
|
248
252
|
</div>
|
|
249
253
|
{#if hasDuplicates}
|
|
250
254
|
<div
|
|
251
|
-
class="
|
|
255
|
+
class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
|
|
252
256
|
>
|
|
253
257
|
<div class="font-medium">{t.duplicateFilesTitle}</div>
|
|
254
258
|
<div class="mt-1 text-xs">
|
|
@@ -264,7 +268,7 @@
|
|
|
264
268
|
{/if}
|
|
265
269
|
{#if downloadError}
|
|
266
270
|
<div
|
|
267
|
-
class="
|
|
271
|
+
class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
|
|
268
272
|
>
|
|
269
273
|
{downloadError}
|
|
270
274
|
</div>
|
|
@@ -282,7 +286,10 @@
|
|
|
282
286
|
|
|
283
287
|
<!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
|
|
284
288
|
{#if item.widgetType !== 'dynamicValueList'}
|
|
285
|
-
<div
|
|
289
|
+
<div
|
|
290
|
+
role="group"
|
|
291
|
+
class="group/field flex flex-col {item.widgetType === 'number' ? 'gap-0.5' : 'gap-2'}"
|
|
292
|
+
>
|
|
286
293
|
{@render fieldHeader()}
|
|
287
294
|
|
|
288
295
|
{#if item.widgetType === 'chart'}
|
|
@@ -295,18 +302,18 @@
|
|
|
295
302
|
{:else if item.widgetType === 'file'}
|
|
296
303
|
{@render fileDisplay()}
|
|
297
304
|
{:else if item.widgetType === 'number'}
|
|
298
|
-
<div class="
|
|
305
|
+
<div class="min-h-8 text-2xl font-semibold wrap-break-word text-foreground tabular-nums">
|
|
299
306
|
{#if hasValue}
|
|
300
|
-
|
|
307
|
+
{formattedValue}
|
|
301
308
|
{:else}
|
|
302
|
-
{@render placeholder()}
|
|
309
|
+
<span class="text-base font-normal">{@render placeholder()}</span>
|
|
303
310
|
{/if}
|
|
304
311
|
</div>
|
|
305
312
|
{:else if item.widgetType === 'text'}
|
|
306
313
|
<div class="group relative">
|
|
307
314
|
{#if isObjectValue}
|
|
308
315
|
<pre
|
|
309
|
-
class="{boxClass} max-h-96 overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
|
|
316
|
+
class="{boxClass} max-h-96 font-mono overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
|
|
310
317
|
{:else}
|
|
311
318
|
<div
|
|
312
319
|
class="{boxClass} bg-muted/10 wrap-anywhere wrap-break-word whitespace-pre-wrap text-foreground"
|
|
@@ -33,9 +33,11 @@
|
|
|
33
33
|
* repaint, so the row updates instantly and the geometry lags behind.
|
|
34
34
|
*/
|
|
35
35
|
onVisibilityChange?: () => void;
|
|
36
|
+
/** The entry under the pointer, or null once it leaves. Drives the viewport preview tint. */
|
|
37
|
+
onHover?: (entry: SceneEntry | null) => void;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
|
|
40
|
+
let { outliner, sceneVersion = 0, onVisibilityChange, onHover }: Props = $props();
|
|
39
41
|
|
|
40
42
|
// A partially hidden layer hides the rest; only a fully hidden one comes back — the same rule
|
|
41
43
|
// `visibility.toggleLayer` applies to objects, restated over entries because a layer's rows are
|
|
@@ -51,6 +53,13 @@
|
|
|
51
53
|
onVisibilityChange?.();
|
|
52
54
|
};
|
|
53
55
|
|
|
56
|
+
// A solve or closing the panel removes the row without a mouseleave, which would leave the
|
|
57
|
+
// preview tint stuck on the viewport.
|
|
58
|
+
$effect(() => {
|
|
59
|
+
void sceneVersion;
|
|
60
|
+
return () => onHover?.(null);
|
|
61
|
+
});
|
|
62
|
+
|
|
54
63
|
// Derived, not destructured: the prop is reassignable, and these must follow it.
|
|
55
64
|
const hidden = $derived(outliner.visibility.hidden);
|
|
56
65
|
const selected = $derived(outliner.selection.selected);
|
|
@@ -331,6 +340,8 @@
|
|
|
331
340
|
class="gap-1.5 pl-5 pr-2 flex h-full cursor-pointer items-center transition-colors
|
|
332
341
|
{isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
|
|
333
342
|
{isHidden ? 'opacity-40' : ''}"
|
|
343
|
+
onmouseenter={() => onHover?.(entry)}
|
|
344
|
+
onmouseleave={() => onHover?.(null)}
|
|
334
345
|
onmousedown={(e) => {
|
|
335
346
|
e.stopPropagation();
|
|
336
347
|
if (e.shiftKey) e.preventDefault();
|
|
@@ -129,6 +129,8 @@
|
|
|
129
129
|
let updateShadowBounds: (() => void) | null = null;
|
|
130
130
|
let fitToView: (() => void) | null = null;
|
|
131
131
|
let clearSelection: (() => void) | null = null;
|
|
132
|
+
let selectEntries: ThreeViewer['selectEntries'] | null = null;
|
|
133
|
+
let setHover: ThreeViewer['setHover'] | null = null;
|
|
132
134
|
let viewerInitialized = false;
|
|
133
135
|
let sceneVersion = $state(0);
|
|
134
136
|
let hideButton = $state(false);
|
|
@@ -203,8 +205,8 @@
|
|
|
203
205
|
events: {
|
|
204
206
|
// Fires before onMeshMetadataClicked, so picking an object without metadata closes the
|
|
205
207
|
// previous object's panel instead of leaving it describing the wrong thing.
|
|
206
|
-
onObjectSelected:
|
|
207
|
-
onBackgroundClicked:
|
|
208
|
+
onObjectSelected: dropPanelSelection,
|
|
209
|
+
onBackgroundClicked: dropPanelSelection,
|
|
208
210
|
onMeshMetadataClicked: config.enableMeshClick
|
|
209
211
|
? (metadata: Record<string, unknown>) => {
|
|
210
212
|
if (hasUsefulMetadata(metadata)) {
|
|
@@ -236,6 +238,8 @@
|
|
|
236
238
|
updateShadowBounds = init.updateShadowBounds;
|
|
237
239
|
fitToView = init.fitToView;
|
|
238
240
|
clearSelection = init.clearSelection;
|
|
241
|
+
selectEntries = init.selectEntries;
|
|
242
|
+
setHover = init.setHover;
|
|
239
243
|
projection = init.cameraController.getProjection();
|
|
240
244
|
|
|
241
245
|
// Untracked so the host reading `meshes` or config inside its setup can't re-run onMount's teardown.
|
|
@@ -389,6 +393,24 @@
|
|
|
389
393
|
selectedMeshName = null;
|
|
390
394
|
}
|
|
391
395
|
|
|
396
|
+
// A click on the canvas replaces whatever the panel selected: one selection at a time.
|
|
397
|
+
function dropPanelSelection() {
|
|
398
|
+
closeMetadata();
|
|
399
|
+
selectedObjects.clear();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Mirrors the panel's selection into the viewport. The size check skips walking the entries for
|
|
403
|
+
// the common empty case, which also keeps `has()` from subscribing to every key.
|
|
404
|
+
$effect(() => {
|
|
405
|
+
if (selectedObjects.size === 0) {
|
|
406
|
+
selectEntries?.([]);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const chosen = outliner?.entries().filter((entry) => selectedObjects.has(entry.key)) ?? [];
|
|
410
|
+
closeMetadata();
|
|
411
|
+
selectEntries?.(chosen);
|
|
412
|
+
});
|
|
413
|
+
|
|
392
414
|
function dismissMetadata() {
|
|
393
415
|
closeMetadata();
|
|
394
416
|
clearSelection?.();
|
|
@@ -655,7 +677,12 @@
|
|
|
655
677
|
{#if sceneManagerOpen && scene && outliner}
|
|
656
678
|
<Resizable.Handle withHandle />
|
|
657
679
|
<Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
|
|
658
|
-
<SceneManager
|
|
680
|
+
<SceneManager
|
|
681
|
+
{outliner}
|
|
682
|
+
{sceneVersion}
|
|
683
|
+
onVisibilityChange={() => invalidate?.()}
|
|
684
|
+
onHover={(entry) => setHover?.(entry?.object ?? null, entry?.memberIndex ?? null)}
|
|
685
|
+
/>
|
|
659
686
|
</Resizable.Pane>
|
|
660
687
|
{/if}
|
|
661
688
|
</Resizable.PaneGroup>
|