@selvajs/ui 6.6.0-beta.11 → 6.6.0-beta.12
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/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
|
@@ -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"
|
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.12",
|
|
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
|
},
|
|
@@ -60,9 +64,9 @@
|
|
|
60
64
|
"tailwind-variants": "^3.3.1",
|
|
61
65
|
"three": "^0.186.1",
|
|
62
66
|
"@selvajs/compute": "^5.0.0-beta.8",
|
|
63
|
-
"@selvajs/schemas": "^5.3.0-beta.1",
|
|
64
67
|
"@selvajs/solve": "^2.0.0-beta.10",
|
|
65
|
-
"@selvajs/visualization": "^1.6.0-beta.
|
|
68
|
+
"@selvajs/visualization": "^1.6.0-beta.6",
|
|
69
|
+
"@selvajs/schemas": "^5.3.0-beta.1"
|
|
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.6",
|
|
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"
|