@selvajs/ui 0.9.2 → 0.9.3
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 +23 -3
- package/dist/components/compute/CollapsedPanelStrip.svelte +30 -25
- package/dist/components/compute/ComputeApp.svelte +22 -4
- package/dist/components/compute/ComputeApp.svelte.d.ts +5 -0
- package/dist/components/preview/Group.svelte +57 -7
- package/dist/components/preview/InputControl.svelte +18 -0
- package/dist/components/preview/TabBar.svelte +18 -1
- package/dist/components/primitives/resizable/resizable-handle.svelte +4 -2
- package/dist/external/storage.d.ts +15 -0
- package/dist/external/storage.js +70 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/schema/visibility-rules.js +3 -0
- package/package.json +101 -99
- package/src/lib/components/compute/AppLayout.svelte +23 -3
- package/src/lib/components/compute/CollapsedPanelStrip.svelte +30 -25
- package/src/lib/components/compute/ComputeApp.svelte +22 -4
- package/src/lib/components/preview/Group.svelte +57 -7
- package/src/lib/components/preview/InputControl.svelte +18 -0
- package/src/lib/components/preview/TabBar.svelte +18 -1
- package/src/lib/components/primitives/resizable/resizable-handle.svelte +4 -2
- package/src/lib/external/storage.ts +80 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/schema/visibility-rules.ts +3 -0
|
@@ -156,7 +156,9 @@
|
|
|
156
156
|
|
|
157
157
|
<div
|
|
158
158
|
data-layout-root
|
|
159
|
-
class="min-h-0 sm:flex-row sm:
|
|
159
|
+
class="min-h-0 sm:flex-row sm:py-(--page-py) flex flex-1 flex-col overflow-hidden {leftCollapsed
|
|
160
|
+
? 'sm:pl-0'
|
|
161
|
+
: 'sm:pl-(--page-px)'} {rightCollapsed ? 'sm:pr-0' : 'sm:pr-(--page-px)'}"
|
|
160
162
|
class:fullscreen-layout={isViewerFullscreen}
|
|
161
163
|
class:relative={isMobile}
|
|
162
164
|
>
|
|
@@ -304,6 +306,7 @@
|
|
|
304
306
|
collapsedSize={0}
|
|
305
307
|
onCollapse={() => (leftCollapsed = true)}
|
|
306
308
|
onExpand={() => (leftCollapsed = false)}
|
|
309
|
+
class="ml-1"
|
|
307
310
|
>
|
|
308
311
|
<div
|
|
309
312
|
class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
|
|
@@ -314,7 +317,12 @@
|
|
|
314
317
|
</div>
|
|
315
318
|
</Resizable.Pane>
|
|
316
319
|
<Resizable.Handle
|
|
317
|
-
|
|
320
|
+
withHandle
|
|
321
|
+
class="bg-transparent"
|
|
322
|
+
data-collapsed={leftCollapsed}
|
|
323
|
+
onclick={() => {
|
|
324
|
+
if (leftCollapsed) leftPaneRef?.expand();
|
|
325
|
+
}}
|
|
318
326
|
/>
|
|
319
327
|
{/if}
|
|
320
328
|
|
|
@@ -335,7 +343,12 @@
|
|
|
335
343
|
<!-- Right pane -->
|
|
336
344
|
{#if hasRightPanel}
|
|
337
345
|
<Resizable.Handle
|
|
338
|
-
|
|
346
|
+
withHandle
|
|
347
|
+
class="bg-transparent"
|
|
348
|
+
data-collapsed={rightCollapsed}
|
|
349
|
+
onclick={() => {
|
|
350
|
+
if (rightCollapsed) rightPaneRef?.expand();
|
|
351
|
+
}}
|
|
339
352
|
/>
|
|
340
353
|
<Resizable.Pane
|
|
341
354
|
bind:this={rightPaneRef}
|
|
@@ -347,6 +360,7 @@
|
|
|
347
360
|
collapsedSize={0}
|
|
348
361
|
onCollapse={() => (rightCollapsed = true)}
|
|
349
362
|
onExpand={() => (rightCollapsed = false)}
|
|
363
|
+
class="mr-1"
|
|
350
364
|
>
|
|
351
365
|
<div
|
|
352
366
|
class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
|
|
@@ -386,6 +400,12 @@
|
|
|
386
400
|
padding: 0 !important;
|
|
387
401
|
}
|
|
388
402
|
|
|
403
|
+
/* When the adjacent pane is collapsed, override paneforge's inline
|
|
404
|
+
ew-resize cursor — there's nothing to drag from in this state. */
|
|
405
|
+
:global([data-pane-resizer][data-collapsed='true']) {
|
|
406
|
+
cursor: pointer !important;
|
|
407
|
+
}
|
|
408
|
+
|
|
389
409
|
.panel-content-wrapper {
|
|
390
410
|
display: flex;
|
|
391
411
|
flex-direction: column;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { TabConfig } from '@selvajs/schemas';
|
|
3
|
-
import { ChevronRight, ChevronLeft, ChevronDown } from '@lucide/svelte';
|
|
4
3
|
import Icon from '@iconify/svelte';
|
|
5
4
|
|
|
6
5
|
interface Props {
|
|
@@ -12,29 +11,34 @@
|
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
let { side, tabs, collapsedWidth, onExpand, onTabClick }: Props = $props();
|
|
14
|
+
|
|
15
|
+
const railClass = $derived(
|
|
16
|
+
[
|
|
17
|
+
'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
|
|
18
|
+
'flex w-full shrink-0 flex-row items-center',
|
|
19
|
+
'backdrop-blur-sm bg-background/90 cursor-pointer'
|
|
20
|
+
].join(' ')
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const tabButtonClass =
|
|
24
|
+
'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';
|
|
15
25
|
</script>
|
|
16
26
|
|
|
17
27
|
<div
|
|
18
|
-
class=
|
|
19
|
-
style="
|
|
28
|
+
class={railClass}
|
|
29
|
+
style="--collapsed-w: {collapsedWidth}px;"
|
|
20
30
|
role="button"
|
|
21
31
|
tabindex="0"
|
|
32
|
+
aria-label="Expand {side} panel"
|
|
22
33
|
onclick={onExpand}
|
|
23
|
-
onkeydown={(e) => e.key === 'Enter' && onExpand()}
|
|
24
|
-
title="Expand {side} panel"
|
|
34
|
+
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onExpand()}
|
|
25
35
|
>
|
|
26
|
-
<!-- Mobile chevron (top of row) -->
|
|
27
|
-
{#if side === 'left'}
|
|
28
|
-
<div class="lg:hidden text-muted-foreground">
|
|
29
|
-
<ChevronDown size={14} />
|
|
30
|
-
</div>
|
|
31
|
-
{/if}
|
|
32
|
-
|
|
33
36
|
{#each tabs as tab (tab.id)}
|
|
34
37
|
<button
|
|
35
38
|
type="button"
|
|
36
|
-
class=
|
|
39
|
+
class={tabButtonClass}
|
|
37
40
|
title={tab.label}
|
|
41
|
+
aria-label={tab.label}
|
|
38
42
|
onclick={(e) => {
|
|
39
43
|
e.stopPropagation();
|
|
40
44
|
onTabClick(tab.id);
|
|
@@ -44,21 +48,22 @@
|
|
|
44
48
|
{#if tab.icon.includes(':')}
|
|
45
49
|
<Icon icon={tab.icon} class="h-4 w-4" />
|
|
46
50
|
{:else}
|
|
47
|
-
<span>{tab.icon}</span>
|
|
51
|
+
<span class="text-xs font-semibold">{tab.icon}</span>
|
|
48
52
|
{/if}
|
|
49
53
|
{:else}
|
|
50
|
-
<span>{tab.label[0]?.toUpperCase() ?? '?'}</span>
|
|
54
|
+
<span class="text-xs font-semibold">{tab.label[0]?.toUpperCase() ?? '?'}</span>
|
|
51
55
|
{/if}
|
|
52
56
|
</button>
|
|
53
57
|
{/each}
|
|
54
|
-
|
|
55
|
-
{#if side === 'left'}
|
|
56
|
-
<div class="lg:block mt-auto hidden text-muted-foreground">
|
|
57
|
-
<ChevronRight size={14} />
|
|
58
|
-
</div>
|
|
59
|
-
{:else if side === 'right'}
|
|
60
|
-
<div class="lg:block mt-auto hidden text-muted-foreground">
|
|
61
|
-
<ChevronLeft size={14} />
|
|
62
|
-
</div>
|
|
63
|
-
{/if}
|
|
64
58
|
</div>
|
|
59
|
+
|
|
60
|
+
<style>
|
|
61
|
+
div {
|
|
62
|
+
min-width: 0;
|
|
63
|
+
}
|
|
64
|
+
@media (min-width: 1024px) {
|
|
65
|
+
div {
|
|
66
|
+
width: var(--collapsed-w);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
</style>
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import AppShell from '../layout/AppShell.svelte';
|
|
13
13
|
import AppLayout from './AppLayout.svelte';
|
|
14
14
|
import StateDisplay from '../primitives/StateDisplay.svelte';
|
|
15
|
+
import { getExternalInputs, readExternalValue } from '../../external/storage';
|
|
15
16
|
|
|
16
17
|
import type { Snippet } from 'svelte';
|
|
17
18
|
|
|
@@ -34,6 +35,11 @@
|
|
|
34
35
|
footerItemPriority?: number;
|
|
35
36
|
onReady?: (api: { loadValues: (values: Record<string, unknown>) => void }) => void;
|
|
36
37
|
headerRight?: Snippet;
|
|
38
|
+
/**
|
|
39
|
+
* Stable identifier used to scope sessionStorage entries for external-input
|
|
40
|
+
* values. If absent, falls back to definitionKey, then to schema.id.
|
|
41
|
+
*/
|
|
42
|
+
externalScopeKey?: string;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
let {
|
|
@@ -53,12 +59,22 @@
|
|
|
53
59
|
footerItemId = 'footer-item',
|
|
54
60
|
footerItemPriority = 0,
|
|
55
61
|
headerRight,
|
|
56
|
-
onReady
|
|
62
|
+
onReady,
|
|
63
|
+
externalScopeKey
|
|
57
64
|
}: Props = $props();
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
const resolvedScopeKey = $derived(externalScopeKey || definitionKey || schema?.id || '');
|
|
67
|
+
|
|
68
|
+
function createInitialValues(s: UISchema, scopeKey: string) {
|
|
69
|
+
const externalSet = new Set(getExternalInputs(s).map((e) => e.paramId));
|
|
60
70
|
const v: Record<string, unknown> = {};
|
|
61
71
|
for (const input of s.inputs) {
|
|
72
|
+
if (externalSet.has(input.id)) {
|
|
73
|
+
const stored = readExternalValue({ scopeKey, inputId: input.id });
|
|
74
|
+
if (stored !== undefined) v[input.id] = stored;
|
|
75
|
+
// else: leave undefined so the missing-inputs panel can detect it
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
62
78
|
v[input.id] = input.default ?? getDefaultValue(input.paramType);
|
|
63
79
|
}
|
|
64
80
|
for (const output of s.outputs) {
|
|
@@ -68,7 +84,9 @@
|
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
// svelte-ignore state_referenced_locally
|
|
71
|
-
let values = $state<Record<string, unknown>>(
|
|
87
|
+
let values = $state<Record<string, unknown>>(
|
|
88
|
+
createInitialValues(schema, externalScopeKey || definitionKey || schema?.id || '')
|
|
89
|
+
);
|
|
72
90
|
let error = $state('');
|
|
73
91
|
let computeErrors = $state<string[]>([]);
|
|
74
92
|
let computeWarnings = $state<string[]>([]);
|
|
@@ -144,7 +162,7 @@
|
|
|
144
162
|
}
|
|
145
163
|
} else if (previousDefinitionKey !== definitionKey) {
|
|
146
164
|
meshes = [];
|
|
147
|
-
values = createInitialValues(schema);
|
|
165
|
+
values = createInitialValues(schema, resolvedScopeKey);
|
|
148
166
|
error = '';
|
|
149
167
|
computeErrors = [];
|
|
150
168
|
computeWarnings = [];
|
|
@@ -23,6 +23,11 @@ interface Props {
|
|
|
23
23
|
loadValues: (values: Record<string, unknown>) => void;
|
|
24
24
|
}) => void;
|
|
25
25
|
headerRight?: Snippet;
|
|
26
|
+
/**
|
|
27
|
+
* Stable identifier used to scope sessionStorage entries for external-input
|
|
28
|
+
* values. If absent, falls back to definitionKey, then to schema.id.
|
|
29
|
+
*/
|
|
30
|
+
externalScopeKey?: string;
|
|
26
31
|
}
|
|
27
32
|
declare const ComputeApp: import("svelte").Component<Props, {}, "">;
|
|
28
33
|
type ComputeApp = ReturnType<typeof ComputeApp>;
|
|
@@ -43,13 +43,40 @@
|
|
|
43
43
|
onToggle();
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute the column position (0-indexed) where each item starts, accounting
|
|
49
|
+
* for spans and linebreak resets. Items hidden by visibility don't consume
|
|
50
|
+
* a slot (matching what the renderer does — they're skipped entirely).
|
|
51
|
+
*/
|
|
52
|
+
const columnStarts = $derived.by(() => {
|
|
53
|
+
const positions: number[] = [];
|
|
54
|
+
let col = 0;
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
if (item.type === 'linebreak') {
|
|
57
|
+
positions.push(0);
|
|
58
|
+
col = 0;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const visibility = evaluateVisibility(item, values);
|
|
62
|
+
if (!visibility.visible) {
|
|
63
|
+
positions.push(0);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const span = Math.min(Math.max(1, item.span ?? 1), columns);
|
|
67
|
+
if (col + span > columns) col = 0;
|
|
68
|
+
positions.push(col);
|
|
69
|
+
col = (col + span) % columns;
|
|
70
|
+
}
|
|
71
|
+
return positions;
|
|
72
|
+
});
|
|
46
73
|
</script>
|
|
47
74
|
|
|
48
75
|
{#if flat}
|
|
49
76
|
<div class="p-6">
|
|
50
77
|
<div class="schema-grid gap-6 grid" style="--schema-cols: {columns};">
|
|
51
|
-
{#each items as layoutItem (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
|
|
52
|
-
{@render gridItem(layoutItem, columns)}
|
|
78
|
+
{#each items as layoutItem, i (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
|
|
79
|
+
{@render gridItem(layoutItem, columns, columnStarts[i] === 0)}
|
|
53
80
|
{/each}
|
|
54
81
|
</div>
|
|
55
82
|
</div>
|
|
@@ -79,8 +106,8 @@
|
|
|
79
106
|
<div class="content-inner">
|
|
80
107
|
<Card.Content class="p-6">
|
|
81
108
|
<div class="schema-grid gap-6 grid" style="--schema-cols: {columns};">
|
|
82
|
-
{#each items as layoutItem (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
|
|
83
|
-
{@render gridItem(layoutItem, columns)}
|
|
109
|
+
{#each items as layoutItem, i (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
|
|
110
|
+
{@render gridItem(layoutItem, columns, columnStarts[i] === 0)}
|
|
84
111
|
{/each}
|
|
85
112
|
</div>
|
|
86
113
|
</Card.Content>
|
|
@@ -89,7 +116,7 @@
|
|
|
89
116
|
</Card.Root>
|
|
90
117
|
{/if}
|
|
91
118
|
|
|
92
|
-
{#snippet gridItem(layoutItem: LayoutItem, cols: number)}
|
|
119
|
+
{#snippet gridItem(layoutItem: LayoutItem, cols: number, isFirstInRow: boolean)}
|
|
93
120
|
{#if layoutItem.type === 'linebreak'}
|
|
94
121
|
<div style="grid-column: 1 / -1" class="h-px bg-border" aria-hidden="true"></div>
|
|
95
122
|
{:else}
|
|
@@ -98,14 +125,19 @@
|
|
|
98
125
|
{#if visibility.visible}
|
|
99
126
|
{#if layoutItem.type === 'input'}
|
|
100
127
|
<div
|
|
101
|
-
class="min-w-0 flex items-center"
|
|
128
|
+
class="grid-cell min-w-0 flex items-center"
|
|
102
129
|
class:opacity-50={visibility.disabled}
|
|
130
|
+
class:col-divider={!isFirstInRow && cols > 1}
|
|
103
131
|
style="grid-column: span {span} / span {span}"
|
|
104
132
|
>
|
|
105
133
|
{@render inputSnippet(layoutItem, visibility)}
|
|
106
134
|
</div>
|
|
107
135
|
{:else if layoutItem.type === 'output'}
|
|
108
|
-
<div
|
|
136
|
+
<div
|
|
137
|
+
class="grid-cell"
|
|
138
|
+
class:col-divider={!isFirstInRow && cols > 1}
|
|
139
|
+
style="grid-column: span {span} / span {span}"
|
|
140
|
+
>
|
|
109
141
|
{@render outputSnippet(layoutItem)}
|
|
110
142
|
</div>
|
|
111
143
|
{/if}
|
|
@@ -131,4 +163,22 @@
|
|
|
131
163
|
.schema-grid {
|
|
132
164
|
grid-template-columns: repeat(var(--schema-cols), minmax(0, 1fr));
|
|
133
165
|
}
|
|
166
|
+
|
|
167
|
+
/* Vertical divider painted in the column gap. `gap-6` is 24px, so a 12px
|
|
168
|
+
negative margin + 12px padding centres the 1px line in the gutter. */
|
|
169
|
+
.col-divider {
|
|
170
|
+
border-left: 1px solid var(--border);
|
|
171
|
+
padding-left: 12px;
|
|
172
|
+
margin-left: -12px;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* When the container collapses the grid to 1 visual column, the items are
|
|
176
|
+
stacked and the divider would float on the left of every row. Hide it. */
|
|
177
|
+
@container (max-width: 320px) {
|
|
178
|
+
.col-divider {
|
|
179
|
+
border-left: 0;
|
|
180
|
+
padding-left: 0;
|
|
181
|
+
margin-left: 0;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
134
184
|
</style>
|
|
@@ -41,6 +41,21 @@
|
|
|
41
41
|
const inputId = $derived(`input-${item.paramId}`);
|
|
42
42
|
const label = $derived(displayName || item.displayName || item.paramId);
|
|
43
43
|
|
|
44
|
+
// Number range hint — shown next to label for sliders, under the input for plain number fields.
|
|
45
|
+
const numberRangeHint = $derived.by(() => {
|
|
46
|
+
if (!isNumberWidget(item)) return null;
|
|
47
|
+
const cfg = item.config;
|
|
48
|
+
if (cfg?.hideRange) return null;
|
|
49
|
+
const hasMin = typeof cfg?.minimum === 'number';
|
|
50
|
+
const hasMax = typeof cfg?.maximum === 'number';
|
|
51
|
+
if (!hasMin && !hasMax) return null;
|
|
52
|
+
if (hasMin && hasMax) return `${cfg!.minimum} to ${cfg!.maximum}`;
|
|
53
|
+
if (hasMin) return `≥ ${cfg!.minimum}`;
|
|
54
|
+
return `≤ ${cfg!.maximum}`;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
|
|
58
|
+
|
|
44
59
|
function commit(newValue: SupportedTypes) {
|
|
45
60
|
value = newValue;
|
|
46
61
|
onChange(item.paramId, newValue);
|
|
@@ -63,6 +78,9 @@
|
|
|
63
78
|
</Dialog.Content>
|
|
64
79
|
</Dialog.Root>
|
|
65
80
|
{/if}
|
|
81
|
+
{#if showRangeInLabel}
|
|
82
|
+
<span class="text-xs font-normal text-muted-foreground">{numberRangeHint}</span>
|
|
83
|
+
{/if}
|
|
66
84
|
</Field.Label>
|
|
67
85
|
|
|
68
86
|
{#if isNumberWidget(item)}
|
|
@@ -10,9 +10,26 @@
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
let { tabs, onTabChange }: Props = $props();
|
|
13
|
+
|
|
14
|
+
let viewportRef = $state<HTMLElement | null>(null);
|
|
15
|
+
|
|
16
|
+
function handleWheel(e: WheelEvent) {
|
|
17
|
+
if (!viewportRef) return;
|
|
18
|
+
// Trackpad horizontal swipes already produce deltaX — let those pass through.
|
|
19
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
|
20
|
+
const canScroll = viewportRef.scrollWidth > viewportRef.clientWidth;
|
|
21
|
+
if (!canScroll) return;
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
viewportRef.scrollLeft += e.deltaY;
|
|
24
|
+
}
|
|
13
25
|
</script>
|
|
14
26
|
|
|
15
|
-
<ScrollArea
|
|
27
|
+
<ScrollArea
|
|
28
|
+
bind:viewportRef
|
|
29
|
+
class="w-full shrink-0 border-b border-border"
|
|
30
|
+
orientation="horizontal"
|
|
31
|
+
onwheel={handleWheel}
|
|
32
|
+
>
|
|
16
33
|
<Tabs.List
|
|
17
34
|
class="px-2 py-2 gap-0 inline-flex h-auto w-max justify-start rounded-none bg-transparent"
|
|
18
35
|
>
|
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
bind:ref
|
|
17
17
|
data-slot="resizable-handle"
|
|
18
18
|
class={cn(
|
|
19
|
-
'cn-resizable-handle after:inset-y-0 after:w-1 data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:translate-x-0 relative flex w-px items-center justify-center bg-border after:absolute after:left-1/2 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90',
|
|
19
|
+
'cn-resizable-handle group/handle after:inset-y-0 after:w-1 data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:translate-x-0 relative flex w-px items-center justify-center bg-border after:absolute after:left-1/2 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90',
|
|
20
20
|
className
|
|
21
21
|
)}
|
|
22
22
|
{...restProps}
|
|
23
23
|
>
|
|
24
24
|
{#if withHandle}
|
|
25
|
-
<div
|
|
25
|
+
<div
|
|
26
|
+
class="h-10 w-1 z-10 flex shrink-0 rounded-lg bg-border transition-[background-color,transform] duration-150 group-hover/handle:bg-foreground/40 group-hover/handle:scale-y-110 group-active/handle:bg-foreground/60"
|
|
27
|
+
></div>
|
|
26
28
|
{/if}
|
|
27
29
|
</ResizablePrimitive.PaneResizer>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { UISchema } from '@selvajs/schemas';
|
|
2
|
+
export interface ExternalValueRef {
|
|
3
|
+
scopeKey: string;
|
|
4
|
+
inputId: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function writeExternalValue(args: ExternalValueRef & {
|
|
7
|
+
value: unknown;
|
|
8
|
+
}): void;
|
|
9
|
+
export declare function readExternalValue(ref: ExternalValueRef): unknown | undefined;
|
|
10
|
+
export declare function clearExternalValue(ref: ExternalValueRef): void;
|
|
11
|
+
export interface ExternalInput {
|
|
12
|
+
paramId: string;
|
|
13
|
+
displayName: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function getExternalInputs(schema: UISchema): ExternalInput[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// External-input transit storage.
|
|
2
|
+
//
|
|
3
|
+
// When an input has source.kind === 'external', a producer route writes the produced
|
|
4
|
+
// value here, and the solver route reads it back. Scoped per (scopeKey, inputId) so
|
|
5
|
+
// values for one solver/input don't bleed into another. The scope key is whatever
|
|
6
|
+
// uniquely identifies the solver context — sessionId in builder-app/preview,
|
|
7
|
+
// definition guid in compute-app/library, etc.
|
|
8
|
+
//
|
|
9
|
+
// inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
|
|
10
|
+
const STORAGE_PREFIX = 'external';
|
|
11
|
+
function makeKey(scopeKey, inputId) {
|
|
12
|
+
return `${STORAGE_PREFIX}:${scopeKey}:${inputId}`;
|
|
13
|
+
}
|
|
14
|
+
export function writeExternalValue(args) {
|
|
15
|
+
const { scopeKey, inputId, value } = args;
|
|
16
|
+
if (!scopeKey || !inputId)
|
|
17
|
+
return;
|
|
18
|
+
if (typeof sessionStorage === 'undefined')
|
|
19
|
+
return;
|
|
20
|
+
sessionStorage.setItem(makeKey(scopeKey, inputId), JSON.stringify(value));
|
|
21
|
+
}
|
|
22
|
+
export function readExternalValue(ref) {
|
|
23
|
+
const { scopeKey, inputId } = ref;
|
|
24
|
+
if (!scopeKey || !inputId)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (typeof sessionStorage === 'undefined')
|
|
27
|
+
return undefined;
|
|
28
|
+
const raw = sessionStorage.getItem(makeKey(scopeKey, inputId));
|
|
29
|
+
if (raw === null)
|
|
30
|
+
return undefined;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function clearExternalValue(ref) {
|
|
39
|
+
const { scopeKey, inputId } = ref;
|
|
40
|
+
if (!scopeKey || !inputId)
|
|
41
|
+
return;
|
|
42
|
+
if (typeof sessionStorage === 'undefined')
|
|
43
|
+
return;
|
|
44
|
+
sessionStorage.removeItem(makeKey(scopeKey, inputId));
|
|
45
|
+
}
|
|
46
|
+
function* walkLayoutItems(schema) {
|
|
47
|
+
const groups = schema.layout.type === 'tabbed'
|
|
48
|
+
? schema.layout.tabs.flatMap((t) => t.groups)
|
|
49
|
+
: schema.layout.groups;
|
|
50
|
+
for (const group of groups) {
|
|
51
|
+
for (const item of group.items) {
|
|
52
|
+
yield item;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function getExternalInputs(schema) {
|
|
57
|
+
const result = [];
|
|
58
|
+
for (const item of walkLayoutItems(schema)) {
|
|
59
|
+
if (item.type !== 'input')
|
|
60
|
+
continue;
|
|
61
|
+
const source = item.source;
|
|
62
|
+
if (source?.kind !== 'external')
|
|
63
|
+
continue;
|
|
64
|
+
result.push({
|
|
65
|
+
paramId: item.paramId,
|
|
66
|
+
displayName: item.displayName ?? item.paramId
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { default as StateDisplay } from './components/primitives/StateDisplay.sv
|
|
|
7
7
|
export { default as Viewer } from './components/viewer/Viewer.svelte';
|
|
8
8
|
export * from './schema/defaults';
|
|
9
9
|
export * from './compute/solving.svelte';
|
|
10
|
+
export * from './external/storage';
|
|
10
11
|
export * from './contexts/footerContext.svelte';
|
|
11
12
|
export * from './composables/useFooterItem.svelte';
|
|
12
13
|
export * from './utils';
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,8 @@ export { default as Viewer } from './components/viewer/Viewer.svelte';
|
|
|
13
13
|
// Utilities
|
|
14
14
|
export * from './schema/defaults';
|
|
15
15
|
export * from './compute/solving.svelte';
|
|
16
|
+
// External-input transit storage (used by routes that wire pre-step producers)
|
|
17
|
+
export * from './external/storage';
|
|
16
18
|
// Contexts & Composables
|
|
17
19
|
export * from './contexts/footerContext.svelte';
|
|
18
20
|
export * from './composables/useFooterItem.svelte';
|
|
@@ -47,6 +47,9 @@ const ACTIONS = {
|
|
|
47
47
|
export function evaluateVisibility(item, values) {
|
|
48
48
|
if (item.type === 'linebreak')
|
|
49
49
|
return { visible: true, disabled: false };
|
|
50
|
+
if ('visible' in item && item.visible === false) {
|
|
51
|
+
return { visible: false, disabled: false };
|
|
52
|
+
}
|
|
50
53
|
if (!item.visibilityCondition?.rules)
|
|
51
54
|
return { visible: true, disabled: false };
|
|
52
55
|
const { action = 'show', defaultValue } = item.visibilityCondition;
|
package/package.json
CHANGED
|
@@ -1,100 +1,102 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
2
|
+
"name": "@selvajs/ui",
|
|
3
|
+
"version": "0.9.3",
|
|
4
|
+
"description": "Shared UI components and utilities for Selva applications",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"svelte": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"svelte": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./primitives": {
|
|
18
|
+
"types": "./dist/components/primitives/index.d.ts",
|
|
19
|
+
"svelte": "./dist/components/primitives/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./layout": {
|
|
22
|
+
"types": "./dist/components/layout/index.d.ts",
|
|
23
|
+
"svelte": "./dist/components/layout/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./compute": {
|
|
26
|
+
"types": "./dist/components/compute/index.d.ts",
|
|
27
|
+
"svelte": "./dist/components/compute/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./viewer": {
|
|
30
|
+
"types": "./dist/components/viewer/index.d.ts",
|
|
31
|
+
"svelte": "./dist/components/viewer/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./footer": {
|
|
34
|
+
"types": "./dist/contexts/footerContext.svelte.d.ts",
|
|
35
|
+
"svelte": "./dist/contexts/footerContext.svelte.js"
|
|
36
|
+
},
|
|
37
|
+
"./utils": {
|
|
38
|
+
"types": "./dist/utils.d.ts",
|
|
39
|
+
"import": "./dist/utils.js"
|
|
40
|
+
},
|
|
41
|
+
"./styles/base.css": "./src/lib/styles/base.css",
|
|
42
|
+
"./styles/themes/*": "./src/lib/styles/themes/*"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"src/lib",
|
|
47
|
+
"!dist/**/*.test.*",
|
|
48
|
+
"!dist/**/*.spec.*",
|
|
49
|
+
"!src/lib/**/*.test.*",
|
|
50
|
+
"!src/lib/**/*.spec.*"
|
|
51
|
+
],
|
|
52
|
+
"sideEffects": [
|
|
53
|
+
"**/*.css"
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"dev": "vite dev",
|
|
57
|
+
"build": "rm -rf dist && vite build && pnpm prepack",
|
|
58
|
+
"build:fast": "svelte-kit sync && svelte-package",
|
|
59
|
+
"build:watch": "svelte-kit sync && svelte-package --watch",
|
|
60
|
+
"preview": "vite preview",
|
|
61
|
+
"prepack": "svelte-kit sync && svelte-package && publint",
|
|
62
|
+
"prepare": "svelte-kit sync || echo ''",
|
|
63
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
64
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
65
|
+
"format": "prettier --write .",
|
|
66
|
+
"lint": "prettier --check . && eslint ."
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"@selvajs/compute": "catalog:",
|
|
70
|
+
"@sveltejs/kit": "^2",
|
|
71
|
+
"bits-ui": "^2.15.4",
|
|
72
|
+
"svelte": "^5",
|
|
73
|
+
"tailwind-variants": "^3.2.2",
|
|
74
|
+
"three": "catalog:"
|
|
75
|
+
},
|
|
76
|
+
"peerDependenciesMeta": {
|
|
77
|
+
"three": {
|
|
78
|
+
"optional": true
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"dependencies": {
|
|
82
|
+
"@floating-ui/dom": "catalog:",
|
|
83
|
+
"@iconify/svelte": "catalog:",
|
|
84
|
+
"@lucide/svelte": "catalog:",
|
|
85
|
+
"@selvajs/schemas": "workspace:*",
|
|
86
|
+
"clsx": "catalog:",
|
|
87
|
+
"mode-watcher": "catalog:",
|
|
88
|
+
"paneforge": "^1.0.2",
|
|
89
|
+
"svelte-sonner": "catalog:",
|
|
90
|
+
"tailwind-merge": "catalog:",
|
|
91
|
+
"tw-animate-css": "catalog:"
|
|
92
|
+
},
|
|
93
|
+
"devDependencies": {
|
|
94
|
+
"@internationalized/date": "^3.12.1",
|
|
95
|
+
"@selvajs/config": "workspace:*",
|
|
96
|
+
"@sveltejs/kit": "catalog:",
|
|
97
|
+
"@types/three": "catalog:",
|
|
98
|
+
"bits-ui": "^2.18.0",
|
|
99
|
+
"svelte": "catalog:",
|
|
100
|
+
"tailwind-variants": "^3.2.2"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -156,7 +156,9 @@
|
|
|
156
156
|
|
|
157
157
|
<div
|
|
158
158
|
data-layout-root
|
|
159
|
-
class="min-h-0 sm:flex-row sm:
|
|
159
|
+
class="min-h-0 sm:flex-row sm:py-(--page-py) flex flex-1 flex-col overflow-hidden {leftCollapsed
|
|
160
|
+
? 'sm:pl-0'
|
|
161
|
+
: 'sm:pl-(--page-px)'} {rightCollapsed ? 'sm:pr-0' : 'sm:pr-(--page-px)'}"
|
|
160
162
|
class:fullscreen-layout={isViewerFullscreen}
|
|
161
163
|
class:relative={isMobile}
|
|
162
164
|
>
|
|
@@ -304,6 +306,7 @@
|
|
|
304
306
|
collapsedSize={0}
|
|
305
307
|
onCollapse={() => (leftCollapsed = true)}
|
|
306
308
|
onExpand={() => (leftCollapsed = false)}
|
|
309
|
+
class="ml-1"
|
|
307
310
|
>
|
|
308
311
|
<div
|
|
309
312
|
class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
|
|
@@ -314,7 +317,12 @@
|
|
|
314
317
|
</div>
|
|
315
318
|
</Resizable.Pane>
|
|
316
319
|
<Resizable.Handle
|
|
317
|
-
|
|
320
|
+
withHandle
|
|
321
|
+
class="bg-transparent"
|
|
322
|
+
data-collapsed={leftCollapsed}
|
|
323
|
+
onclick={() => {
|
|
324
|
+
if (leftCollapsed) leftPaneRef?.expand();
|
|
325
|
+
}}
|
|
318
326
|
/>
|
|
319
327
|
{/if}
|
|
320
328
|
|
|
@@ -335,7 +343,12 @@
|
|
|
335
343
|
<!-- Right pane -->
|
|
336
344
|
{#if hasRightPanel}
|
|
337
345
|
<Resizable.Handle
|
|
338
|
-
|
|
346
|
+
withHandle
|
|
347
|
+
class="bg-transparent"
|
|
348
|
+
data-collapsed={rightCollapsed}
|
|
349
|
+
onclick={() => {
|
|
350
|
+
if (rightCollapsed) rightPaneRef?.expand();
|
|
351
|
+
}}
|
|
339
352
|
/>
|
|
340
353
|
<Resizable.Pane
|
|
341
354
|
bind:this={rightPaneRef}
|
|
@@ -347,6 +360,7 @@
|
|
|
347
360
|
collapsedSize={0}
|
|
348
361
|
onCollapse={() => (rightCollapsed = true)}
|
|
349
362
|
onExpand={() => (rightCollapsed = false)}
|
|
363
|
+
class="mr-1"
|
|
350
364
|
>
|
|
351
365
|
<div
|
|
352
366
|
class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
|
|
@@ -386,6 +400,12 @@
|
|
|
386
400
|
padding: 0 !important;
|
|
387
401
|
}
|
|
388
402
|
|
|
403
|
+
/* When the adjacent pane is collapsed, override paneforge's inline
|
|
404
|
+
ew-resize cursor — there's nothing to drag from in this state. */
|
|
405
|
+
:global([data-pane-resizer][data-collapsed='true']) {
|
|
406
|
+
cursor: pointer !important;
|
|
407
|
+
}
|
|
408
|
+
|
|
389
409
|
.panel-content-wrapper {
|
|
390
410
|
display: flex;
|
|
391
411
|
flex-direction: column;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { TabConfig } from '@selvajs/schemas';
|
|
3
|
-
import { ChevronRight, ChevronLeft, ChevronDown } from '@lucide/svelte';
|
|
4
3
|
import Icon from '@iconify/svelte';
|
|
5
4
|
|
|
6
5
|
interface Props {
|
|
@@ -12,29 +11,34 @@
|
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
let { side, tabs, collapsedWidth, onExpand, onTabClick }: Props = $props();
|
|
14
|
+
|
|
15
|
+
const railClass = $derived(
|
|
16
|
+
[
|
|
17
|
+
'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
|
|
18
|
+
'flex w-full shrink-0 flex-row items-center',
|
|
19
|
+
'backdrop-blur-sm bg-background/90 cursor-pointer'
|
|
20
|
+
].join(' ')
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const tabButtonClass =
|
|
24
|
+
'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';
|
|
15
25
|
</script>
|
|
16
26
|
|
|
17
27
|
<div
|
|
18
|
-
class=
|
|
19
|
-
style="
|
|
28
|
+
class={railClass}
|
|
29
|
+
style="--collapsed-w: {collapsedWidth}px;"
|
|
20
30
|
role="button"
|
|
21
31
|
tabindex="0"
|
|
32
|
+
aria-label="Expand {side} panel"
|
|
22
33
|
onclick={onExpand}
|
|
23
|
-
onkeydown={(e) => e.key === 'Enter' && onExpand()}
|
|
24
|
-
title="Expand {side} panel"
|
|
34
|
+
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onExpand()}
|
|
25
35
|
>
|
|
26
|
-
<!-- Mobile chevron (top of row) -->
|
|
27
|
-
{#if side === 'left'}
|
|
28
|
-
<div class="lg:hidden text-muted-foreground">
|
|
29
|
-
<ChevronDown size={14} />
|
|
30
|
-
</div>
|
|
31
|
-
{/if}
|
|
32
|
-
|
|
33
36
|
{#each tabs as tab (tab.id)}
|
|
34
37
|
<button
|
|
35
38
|
type="button"
|
|
36
|
-
class=
|
|
39
|
+
class={tabButtonClass}
|
|
37
40
|
title={tab.label}
|
|
41
|
+
aria-label={tab.label}
|
|
38
42
|
onclick={(e) => {
|
|
39
43
|
e.stopPropagation();
|
|
40
44
|
onTabClick(tab.id);
|
|
@@ -44,21 +48,22 @@
|
|
|
44
48
|
{#if tab.icon.includes(':')}
|
|
45
49
|
<Icon icon={tab.icon} class="h-4 w-4" />
|
|
46
50
|
{:else}
|
|
47
|
-
<span>{tab.icon}</span>
|
|
51
|
+
<span class="text-xs font-semibold">{tab.icon}</span>
|
|
48
52
|
{/if}
|
|
49
53
|
{:else}
|
|
50
|
-
<span>{tab.label[0]?.toUpperCase() ?? '?'}</span>
|
|
54
|
+
<span class="text-xs font-semibold">{tab.label[0]?.toUpperCase() ?? '?'}</span>
|
|
51
55
|
{/if}
|
|
52
56
|
</button>
|
|
53
57
|
{/each}
|
|
54
|
-
|
|
55
|
-
{#if side === 'left'}
|
|
56
|
-
<div class="lg:block mt-auto hidden text-muted-foreground">
|
|
57
|
-
<ChevronRight size={14} />
|
|
58
|
-
</div>
|
|
59
|
-
{:else if side === 'right'}
|
|
60
|
-
<div class="lg:block mt-auto hidden text-muted-foreground">
|
|
61
|
-
<ChevronLeft size={14} />
|
|
62
|
-
</div>
|
|
63
|
-
{/if}
|
|
64
58
|
</div>
|
|
59
|
+
|
|
60
|
+
<style>
|
|
61
|
+
div {
|
|
62
|
+
min-width: 0;
|
|
63
|
+
}
|
|
64
|
+
@media (min-width: 1024px) {
|
|
65
|
+
div {
|
|
66
|
+
width: var(--collapsed-w);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
</style>
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import AppShell from '../layout/AppShell.svelte';
|
|
13
13
|
import AppLayout from './AppLayout.svelte';
|
|
14
14
|
import StateDisplay from '../primitives/StateDisplay.svelte';
|
|
15
|
+
import { getExternalInputs, readExternalValue } from '../../external/storage';
|
|
15
16
|
|
|
16
17
|
import type { Snippet } from 'svelte';
|
|
17
18
|
|
|
@@ -34,6 +35,11 @@
|
|
|
34
35
|
footerItemPriority?: number;
|
|
35
36
|
onReady?: (api: { loadValues: (values: Record<string, unknown>) => void }) => void;
|
|
36
37
|
headerRight?: Snippet;
|
|
38
|
+
/**
|
|
39
|
+
* Stable identifier used to scope sessionStorage entries for external-input
|
|
40
|
+
* values. If absent, falls back to definitionKey, then to schema.id.
|
|
41
|
+
*/
|
|
42
|
+
externalScopeKey?: string;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
let {
|
|
@@ -53,12 +59,22 @@
|
|
|
53
59
|
footerItemId = 'footer-item',
|
|
54
60
|
footerItemPriority = 0,
|
|
55
61
|
headerRight,
|
|
56
|
-
onReady
|
|
62
|
+
onReady,
|
|
63
|
+
externalScopeKey
|
|
57
64
|
}: Props = $props();
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
const resolvedScopeKey = $derived(externalScopeKey || definitionKey || schema?.id || '');
|
|
67
|
+
|
|
68
|
+
function createInitialValues(s: UISchema, scopeKey: string) {
|
|
69
|
+
const externalSet = new Set(getExternalInputs(s).map((e) => e.paramId));
|
|
60
70
|
const v: Record<string, unknown> = {};
|
|
61
71
|
for (const input of s.inputs) {
|
|
72
|
+
if (externalSet.has(input.id)) {
|
|
73
|
+
const stored = readExternalValue({ scopeKey, inputId: input.id });
|
|
74
|
+
if (stored !== undefined) v[input.id] = stored;
|
|
75
|
+
// else: leave undefined so the missing-inputs panel can detect it
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
62
78
|
v[input.id] = input.default ?? getDefaultValue(input.paramType);
|
|
63
79
|
}
|
|
64
80
|
for (const output of s.outputs) {
|
|
@@ -68,7 +84,9 @@
|
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
// svelte-ignore state_referenced_locally
|
|
71
|
-
let values = $state<Record<string, unknown>>(
|
|
87
|
+
let values = $state<Record<string, unknown>>(
|
|
88
|
+
createInitialValues(schema, externalScopeKey || definitionKey || schema?.id || '')
|
|
89
|
+
);
|
|
72
90
|
let error = $state('');
|
|
73
91
|
let computeErrors = $state<string[]>([]);
|
|
74
92
|
let computeWarnings = $state<string[]>([]);
|
|
@@ -144,7 +162,7 @@
|
|
|
144
162
|
}
|
|
145
163
|
} else if (previousDefinitionKey !== definitionKey) {
|
|
146
164
|
meshes = [];
|
|
147
|
-
values = createInitialValues(schema);
|
|
165
|
+
values = createInitialValues(schema, resolvedScopeKey);
|
|
148
166
|
error = '';
|
|
149
167
|
computeErrors = [];
|
|
150
168
|
computeWarnings = [];
|
|
@@ -43,13 +43,40 @@
|
|
|
43
43
|
onToggle();
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute the column position (0-indexed) where each item starts, accounting
|
|
49
|
+
* for spans and linebreak resets. Items hidden by visibility don't consume
|
|
50
|
+
* a slot (matching what the renderer does — they're skipped entirely).
|
|
51
|
+
*/
|
|
52
|
+
const columnStarts = $derived.by(() => {
|
|
53
|
+
const positions: number[] = [];
|
|
54
|
+
let col = 0;
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
if (item.type === 'linebreak') {
|
|
57
|
+
positions.push(0);
|
|
58
|
+
col = 0;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const visibility = evaluateVisibility(item, values);
|
|
62
|
+
if (!visibility.visible) {
|
|
63
|
+
positions.push(0);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const span = Math.min(Math.max(1, item.span ?? 1), columns);
|
|
67
|
+
if (col + span > columns) col = 0;
|
|
68
|
+
positions.push(col);
|
|
69
|
+
col = (col + span) % columns;
|
|
70
|
+
}
|
|
71
|
+
return positions;
|
|
72
|
+
});
|
|
46
73
|
</script>
|
|
47
74
|
|
|
48
75
|
{#if flat}
|
|
49
76
|
<div class="p-6">
|
|
50
77
|
<div class="schema-grid gap-6 grid" style="--schema-cols: {columns};">
|
|
51
|
-
{#each items as layoutItem (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
|
|
52
|
-
{@render gridItem(layoutItem, columns)}
|
|
78
|
+
{#each items as layoutItem, i (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
|
|
79
|
+
{@render gridItem(layoutItem, columns, columnStarts[i] === 0)}
|
|
53
80
|
{/each}
|
|
54
81
|
</div>
|
|
55
82
|
</div>
|
|
@@ -79,8 +106,8 @@
|
|
|
79
106
|
<div class="content-inner">
|
|
80
107
|
<Card.Content class="p-6">
|
|
81
108
|
<div class="schema-grid gap-6 grid" style="--schema-cols: {columns};">
|
|
82
|
-
{#each items as layoutItem (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
|
|
83
|
-
{@render gridItem(layoutItem, columns)}
|
|
109
|
+
{#each items as layoutItem, i (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
|
|
110
|
+
{@render gridItem(layoutItem, columns, columnStarts[i] === 0)}
|
|
84
111
|
{/each}
|
|
85
112
|
</div>
|
|
86
113
|
</Card.Content>
|
|
@@ -89,7 +116,7 @@
|
|
|
89
116
|
</Card.Root>
|
|
90
117
|
{/if}
|
|
91
118
|
|
|
92
|
-
{#snippet gridItem(layoutItem: LayoutItem, cols: number)}
|
|
119
|
+
{#snippet gridItem(layoutItem: LayoutItem, cols: number, isFirstInRow: boolean)}
|
|
93
120
|
{#if layoutItem.type === 'linebreak'}
|
|
94
121
|
<div style="grid-column: 1 / -1" class="h-px bg-border" aria-hidden="true"></div>
|
|
95
122
|
{:else}
|
|
@@ -98,14 +125,19 @@
|
|
|
98
125
|
{#if visibility.visible}
|
|
99
126
|
{#if layoutItem.type === 'input'}
|
|
100
127
|
<div
|
|
101
|
-
class="min-w-0 flex items-center"
|
|
128
|
+
class="grid-cell min-w-0 flex items-center"
|
|
102
129
|
class:opacity-50={visibility.disabled}
|
|
130
|
+
class:col-divider={!isFirstInRow && cols > 1}
|
|
103
131
|
style="grid-column: span {span} / span {span}"
|
|
104
132
|
>
|
|
105
133
|
{@render inputSnippet(layoutItem, visibility)}
|
|
106
134
|
</div>
|
|
107
135
|
{:else if layoutItem.type === 'output'}
|
|
108
|
-
<div
|
|
136
|
+
<div
|
|
137
|
+
class="grid-cell"
|
|
138
|
+
class:col-divider={!isFirstInRow && cols > 1}
|
|
139
|
+
style="grid-column: span {span} / span {span}"
|
|
140
|
+
>
|
|
109
141
|
{@render outputSnippet(layoutItem)}
|
|
110
142
|
</div>
|
|
111
143
|
{/if}
|
|
@@ -131,4 +163,22 @@
|
|
|
131
163
|
.schema-grid {
|
|
132
164
|
grid-template-columns: repeat(var(--schema-cols), minmax(0, 1fr));
|
|
133
165
|
}
|
|
166
|
+
|
|
167
|
+
/* Vertical divider painted in the column gap. `gap-6` is 24px, so a 12px
|
|
168
|
+
negative margin + 12px padding centres the 1px line in the gutter. */
|
|
169
|
+
.col-divider {
|
|
170
|
+
border-left: 1px solid var(--border);
|
|
171
|
+
padding-left: 12px;
|
|
172
|
+
margin-left: -12px;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* When the container collapses the grid to 1 visual column, the items are
|
|
176
|
+
stacked and the divider would float on the left of every row. Hide it. */
|
|
177
|
+
@container (max-width: 320px) {
|
|
178
|
+
.col-divider {
|
|
179
|
+
border-left: 0;
|
|
180
|
+
padding-left: 0;
|
|
181
|
+
margin-left: 0;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
134
184
|
</style>
|
|
@@ -41,6 +41,21 @@
|
|
|
41
41
|
const inputId = $derived(`input-${item.paramId}`);
|
|
42
42
|
const label = $derived(displayName || item.displayName || item.paramId);
|
|
43
43
|
|
|
44
|
+
// Number range hint — shown next to label for sliders, under the input for plain number fields.
|
|
45
|
+
const numberRangeHint = $derived.by(() => {
|
|
46
|
+
if (!isNumberWidget(item)) return null;
|
|
47
|
+
const cfg = item.config;
|
|
48
|
+
if (cfg?.hideRange) return null;
|
|
49
|
+
const hasMin = typeof cfg?.minimum === 'number';
|
|
50
|
+
const hasMax = typeof cfg?.maximum === 'number';
|
|
51
|
+
if (!hasMin && !hasMax) return null;
|
|
52
|
+
if (hasMin && hasMax) return `${cfg!.minimum} to ${cfg!.maximum}`;
|
|
53
|
+
if (hasMin) return `≥ ${cfg!.minimum}`;
|
|
54
|
+
return `≤ ${cfg!.maximum}`;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
|
|
58
|
+
|
|
44
59
|
function commit(newValue: SupportedTypes) {
|
|
45
60
|
value = newValue;
|
|
46
61
|
onChange(item.paramId, newValue);
|
|
@@ -63,6 +78,9 @@
|
|
|
63
78
|
</Dialog.Content>
|
|
64
79
|
</Dialog.Root>
|
|
65
80
|
{/if}
|
|
81
|
+
{#if showRangeInLabel}
|
|
82
|
+
<span class="text-xs font-normal text-muted-foreground">{numberRangeHint}</span>
|
|
83
|
+
{/if}
|
|
66
84
|
</Field.Label>
|
|
67
85
|
|
|
68
86
|
{#if isNumberWidget(item)}
|
|
@@ -10,9 +10,26 @@
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
let { tabs, onTabChange }: Props = $props();
|
|
13
|
+
|
|
14
|
+
let viewportRef = $state<HTMLElement | null>(null);
|
|
15
|
+
|
|
16
|
+
function handleWheel(e: WheelEvent) {
|
|
17
|
+
if (!viewportRef) return;
|
|
18
|
+
// Trackpad horizontal swipes already produce deltaX — let those pass through.
|
|
19
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
|
20
|
+
const canScroll = viewportRef.scrollWidth > viewportRef.clientWidth;
|
|
21
|
+
if (!canScroll) return;
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
viewportRef.scrollLeft += e.deltaY;
|
|
24
|
+
}
|
|
13
25
|
</script>
|
|
14
26
|
|
|
15
|
-
<ScrollArea
|
|
27
|
+
<ScrollArea
|
|
28
|
+
bind:viewportRef
|
|
29
|
+
class="w-full shrink-0 border-b border-border"
|
|
30
|
+
orientation="horizontal"
|
|
31
|
+
onwheel={handleWheel}
|
|
32
|
+
>
|
|
16
33
|
<Tabs.List
|
|
17
34
|
class="px-2 py-2 gap-0 inline-flex h-auto w-max justify-start rounded-none bg-transparent"
|
|
18
35
|
>
|
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
bind:ref
|
|
17
17
|
data-slot="resizable-handle"
|
|
18
18
|
class={cn(
|
|
19
|
-
'cn-resizable-handle after:inset-y-0 after:w-1 data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:translate-x-0 relative flex w-px items-center justify-center bg-border after:absolute after:left-1/2 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90',
|
|
19
|
+
'cn-resizable-handle group/handle after:inset-y-0 after:w-1 data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:translate-x-0 relative flex w-px items-center justify-center bg-border after:absolute after:left-1/2 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90',
|
|
20
20
|
className
|
|
21
21
|
)}
|
|
22
22
|
{...restProps}
|
|
23
23
|
>
|
|
24
24
|
{#if withHandle}
|
|
25
|
-
<div
|
|
25
|
+
<div
|
|
26
|
+
class="h-10 w-1 z-10 flex shrink-0 rounded-lg bg-border transition-[background-color,transform] duration-150 group-hover/handle:bg-foreground/40 group-hover/handle:scale-y-110 group-active/handle:bg-foreground/60"
|
|
27
|
+
></div>
|
|
26
28
|
{/if}
|
|
27
29
|
</ResizablePrimitive.PaneResizer>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// External-input transit storage.
|
|
2
|
+
//
|
|
3
|
+
// When an input has source.kind === 'external', a producer route writes the produced
|
|
4
|
+
// value here, and the solver route reads it back. Scoped per (scopeKey, inputId) so
|
|
5
|
+
// values for one solver/input don't bleed into another. The scope key is whatever
|
|
6
|
+
// uniquely identifies the solver context — sessionId in builder-app/preview,
|
|
7
|
+
// definition guid in compute-app/library, etc.
|
|
8
|
+
//
|
|
9
|
+
// inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
|
|
10
|
+
|
|
11
|
+
import type { UISchema, LayoutItem, GroupConfig, InputSource } from '@selvajs/schemas';
|
|
12
|
+
|
|
13
|
+
const STORAGE_PREFIX = 'external';
|
|
14
|
+
|
|
15
|
+
function makeKey(scopeKey: string, inputId: string): string {
|
|
16
|
+
return `${STORAGE_PREFIX}:${scopeKey}:${inputId}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExternalValueRef {
|
|
20
|
+
scopeKey: string;
|
|
21
|
+
inputId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeExternalValue(args: ExternalValueRef & { value: unknown }): void {
|
|
25
|
+
const { scopeKey, inputId, value } = args;
|
|
26
|
+
if (!scopeKey || !inputId) return;
|
|
27
|
+
if (typeof sessionStorage === 'undefined') return;
|
|
28
|
+
sessionStorage.setItem(makeKey(scopeKey, inputId), JSON.stringify(value));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readExternalValue(ref: ExternalValueRef): unknown | undefined {
|
|
32
|
+
const { scopeKey, inputId } = ref;
|
|
33
|
+
if (!scopeKey || !inputId) return undefined;
|
|
34
|
+
if (typeof sessionStorage === 'undefined') return undefined;
|
|
35
|
+
const raw = sessionStorage.getItem(makeKey(scopeKey, inputId));
|
|
36
|
+
if (raw === null) return undefined;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(raw);
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clearExternalValue(ref: ExternalValueRef): void {
|
|
45
|
+
const { scopeKey, inputId } = ref;
|
|
46
|
+
if (!scopeKey || !inputId) return;
|
|
47
|
+
if (typeof sessionStorage === 'undefined') return;
|
|
48
|
+
sessionStorage.removeItem(makeKey(scopeKey, inputId));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ExternalInput {
|
|
52
|
+
paramId: string;
|
|
53
|
+
displayName: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function* walkLayoutItems(schema: UISchema): Generator<LayoutItem> {
|
|
57
|
+
const groups: GroupConfig[] =
|
|
58
|
+
schema.layout.type === 'tabbed'
|
|
59
|
+
? schema.layout.tabs.flatMap((t) => t.groups)
|
|
60
|
+
: schema.layout.groups;
|
|
61
|
+
for (const group of groups) {
|
|
62
|
+
for (const item of group.items) {
|
|
63
|
+
yield item;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getExternalInputs(schema: UISchema): ExternalInput[] {
|
|
69
|
+
const result: ExternalInput[] = [];
|
|
70
|
+
for (const item of walkLayoutItems(schema)) {
|
|
71
|
+
if (item.type !== 'input') continue;
|
|
72
|
+
const source = (item as { source?: InputSource }).source;
|
|
73
|
+
if (source?.kind !== 'external') continue;
|
|
74
|
+
result.push({
|
|
75
|
+
paramId: item.paramId,
|
|
76
|
+
displayName: item.displayName ?? item.paramId
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -19,6 +19,9 @@ export { default as Viewer } from './components/viewer/Viewer.svelte';
|
|
|
19
19
|
export * from './schema/defaults';
|
|
20
20
|
export * from './compute/solving.svelte';
|
|
21
21
|
|
|
22
|
+
// External-input transit storage (used by routes that wire pre-step producers)
|
|
23
|
+
export * from './external/storage';
|
|
24
|
+
|
|
22
25
|
// Contexts & Composables
|
|
23
26
|
export * from './contexts/footerContext.svelte';
|
|
24
27
|
export * from './composables/useFooterItem.svelte';
|
|
@@ -72,6 +72,9 @@ export function evaluateVisibility(
|
|
|
72
72
|
values: Record<string, unknown>
|
|
73
73
|
): VisibilityResult {
|
|
74
74
|
if (item.type === 'linebreak') return { visible: true, disabled: false };
|
|
75
|
+
if ('visible' in item && item.visible === false) {
|
|
76
|
+
return { visible: false, disabled: false };
|
|
77
|
+
}
|
|
75
78
|
if (!item.visibilityCondition?.rules) return { visible: true, disabled: false };
|
|
76
79
|
|
|
77
80
|
const { action = 'show', defaultValue } = item.visibilityCondition;
|