@selvajs/ui 6.0.0-beta.8 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -7
- package/dist/components/compute/AppLayout.svelte +3 -8
- package/dist/components/compute/AppLayout.svelte.d.ts +0 -2
- package/dist/components/compute/ComputeApp.svelte +30 -43
- package/dist/components/compute/ComputeApp.svelte.d.ts +11 -24
- package/dist/components/compute/ParameterPresetManager.svelte +4 -9
- package/dist/components/compute/ParameterPresetManager.svelte.d.ts +1 -1
- package/dist/components/layout/AppShell.svelte +5 -13
- package/dist/components/layout/AppShell.svelte.d.ts +1 -2
- package/dist/components/layout/EmptyState.svelte +2 -3
- package/dist/components/layout/PageFooter.svelte +0 -4
- package/dist/components/layout/index.js +0 -1
- package/dist/components/preview/Group.svelte +2 -5
- package/dist/components/preview/ImageOutput.svelte +5 -4
- package/dist/components/preview/InputControl.svelte +27 -28
- package/dist/components/preview/InputControl.svelte.d.ts +3 -3
- package/dist/components/preview/OutputDisplay.svelte +3 -6
- package/dist/components/preview/TabLayout.svelte +3 -3
- package/dist/components/preview/inputs/FileInput.svelte +9 -12
- package/dist/components/primitives/field/field-error.svelte +1 -3
- package/dist/components/primitives/index.js +0 -1
- package/dist/components/viewer/SceneManager.svelte +9 -20
- package/dist/components/viewer/SceneManager.svelte.d.ts +3 -3
- package/dist/components/viewer/Viewer.svelte +42 -63
- package/dist/components/viewer/Viewer.svelte.d.ts +8 -19
- package/dist/composables/useFooterItem.svelte.d.ts +3 -4
- package/dist/composables/useFooterItem.svelte.js +3 -3
- package/dist/compute/solving.svelte.d.ts +6 -6
- package/dist/compute/solving.svelte.js +6 -6
- package/dist/compute/useSolveSession.svelte.d.ts +5 -7
- package/dist/compute/useSolveSession.svelte.js +11 -24
- package/dist/constants.js +4 -4
- package/dist/contexts/clientSlotContext.svelte.d.ts +3 -5
- package/dist/contexts/footerContext.svelte.d.ts +6 -8
- package/dist/external/storage.js +2 -3
- package/dist/i18n/localeContext.svelte.d.ts +5 -7
- package/dist/i18n/localeContext.svelte.js +8 -15
- package/dist/i18n/messages.d.ts +0 -2
- package/dist/i18n/messages.js +3 -8
- package/dist/index.js +7 -13
- package/dist/public.js +14 -30
- package/dist/schema/defaults.js +2 -3
- package/dist/schema/dynamic-value-list.js +13 -15
- package/dist/schema/param-exporter.d.ts +7 -14
- package/dist/schema/param-exporter.js +6 -12
- package/dist/schema/traversal.js +3 -4
- package/dist/schema/visibility-rules.d.ts +4 -8
- package/dist/schema/visibility-rules.js +4 -8
- package/dist/types/actionButton.d.ts +1 -25
- package/dist/types/presetLabels.d.ts +2 -3
- package/dist/utils/color.js +6 -9
- package/dist/utils/debounce.d.ts +1 -11
- package/dist/utils/debounce.js +1 -11
- package/dist/utils/loadScript.d.ts +2 -7
- package/dist/utils/loadScript.js +2 -7
- package/package.json +17 -17
- package/src/lib/components/compute/AppLayout.svelte +3 -8
- package/src/lib/components/compute/ComputeApp.svelte +30 -43
- package/src/lib/components/compute/ParameterPresetManager.svelte +4 -9
- package/src/lib/components/layout/AppShell.svelte +5 -13
- package/src/lib/components/layout/EmptyState.svelte +2 -3
- package/src/lib/components/layout/PageFooter.svelte +0 -4
- package/src/lib/components/layout/index.ts +0 -1
- package/src/lib/components/preview/Group.svelte +2 -5
- package/src/lib/components/preview/ImageOutput.svelte +5 -4
- package/src/lib/components/preview/InputControl.svelte +27 -28
- package/src/lib/components/preview/OutputDisplay.svelte +3 -6
- package/src/lib/components/preview/TabLayout.svelte +3 -3
- package/src/lib/components/preview/inputs/FileInput.svelte +9 -12
- package/src/lib/components/primitives/field/field-error.svelte +1 -3
- package/src/lib/components/primitives/index.ts +0 -2
- package/src/lib/components/viewer/SceneManager.svelte +9 -20
- package/src/lib/components/viewer/Viewer.svelte +42 -63
- package/src/lib/composables/useFooterItem.svelte.ts +3 -4
- package/src/lib/compute/solving.svelte.ts +6 -6
- package/src/lib/compute/useSolveSession.svelte.ts +11 -24
- package/src/lib/constants.ts +4 -4
- package/src/lib/contexts/clientSlotContext.svelte.ts +7 -12
- package/src/lib/contexts/footerContext.svelte.ts +6 -8
- package/src/lib/external/storage.ts +2 -3
- package/src/lib/i18n/localeContext.svelte.ts +8 -17
- package/src/lib/i18n/messages.ts +3 -10
- package/src/lib/index.ts +8 -13
- package/src/lib/public.ts +22 -40
- package/src/lib/schema/defaults.ts +2 -3
- package/src/lib/schema/dynamic-value-list.ts +13 -15
- package/src/lib/schema/param-exporter.ts +9 -16
- package/src/lib/schema/traversal.ts +3 -4
- package/src/lib/schema/visibility-rules.ts +4 -8
- package/src/lib/types/actionButton.ts +1 -25
- package/src/lib/types/presetLabels.ts +2 -3
- package/src/lib/utils/color.ts +6 -9
- package/src/lib/utils/debounce.ts +1 -11
- package/src/lib/utils/loadScript.ts +2 -7
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { hexToOklch } from '../../utils/color';
|
|
16
16
|
import AppShell from '../layout/AppShell.svelte';
|
|
17
17
|
import AppLayout from './AppLayout.svelte';
|
|
18
|
+
import { type ViewerConfig } from '../viewer/Viewer.svelte';
|
|
18
19
|
import StateDisplay from '../primitives/StateDisplay.svelte';
|
|
19
20
|
import { setClientSlot, type ClientSlot } from '../../contexts/clientSlotContext.svelte';
|
|
20
21
|
import type { Locale } from '../../i18n/messages';
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
onSolve: SolveFn;
|
|
28
29
|
definitionKey?: string;
|
|
29
30
|
title?: string;
|
|
30
|
-
/**
|
|
31
|
+
/** Watermark shown in the viewer's bottom-right corner. */
|
|
31
32
|
logo?: string;
|
|
32
33
|
isEmbedded?: boolean;
|
|
33
34
|
primaryColor?: string;
|
|
@@ -41,55 +42,44 @@
|
|
|
41
42
|
onListStates?: () => ParameterPreset[] | Promise<ParameterPreset[]>;
|
|
42
43
|
/** Partial overrides for the preset-manager UI strings (e.g. for localization). */
|
|
43
44
|
presetLabels?: Partial<PresetLabels>;
|
|
44
|
-
/** Name
|
|
45
|
+
/** Name in the footer copyright line. Defaults to the brand name ("Selva"). */
|
|
45
46
|
copyrightName?: string;
|
|
46
47
|
/** Fully overrides the footer copyright line. `{name}` and `{year}` are substituted. */
|
|
47
48
|
footerText?: string;
|
|
48
49
|
/**
|
|
49
|
-
* How long one solve may take before the client aborts it (ms).
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* How long one solve may take before the client aborts it (ms). Pass the same value the
|
|
51
|
+
* server enforces (`COMPUTE_SOLVE_DEADLINE_MS`), or the client aborts solves that would
|
|
52
|
+
* have finished.
|
|
52
53
|
*/
|
|
53
54
|
solveDeadlineMs: number;
|
|
54
55
|
footerComponent?: any;
|
|
55
56
|
footerComponentProps?: () => Record<string, unknown>;
|
|
56
57
|
footerItemId?: string;
|
|
57
58
|
footerItemPriority?: number;
|
|
59
|
+
// `onReady` fires once, so the result and session are getters rather than snapshots.
|
|
58
60
|
onReady?: (api: {
|
|
59
61
|
loadValues: (values: Record<string, unknown>) => void;
|
|
60
|
-
/**
|
|
61
|
-
* The last result reported to the session — the one the viewer is showing, carrying
|
|
62
|
-
* `source`/`values` even when a memo hit served it. Null before the first solve.
|
|
63
|
-
* A getter, not a snapshot: `onReady` fires once.
|
|
64
|
-
*/
|
|
62
|
+
/** What the viewer is showing — carries `source`/`values` even on a memo hit. Null before the first solve. */
|
|
65
63
|
getLastResult: () => RetainedSolveResult | null;
|
|
66
64
|
/**
|
|
67
|
-
*
|
|
68
|
-
* `setValue`/`solve` to push inputs, `subscribe` to react to results.
|
|
69
|
-
*
|
|
70
|
-
* A getter for the same reason as `getLastResult`. Values written here go through the
|
|
65
|
+
* For hosts driving solves from their own state. Values written here go through the
|
|
71
66
|
* same throttle and memo as the UI's, so it's safe to call at interaction rate.
|
|
72
67
|
*/
|
|
73
68
|
getSession: () => SolveSession;
|
|
74
69
|
}) => void;
|
|
75
|
-
/**
|
|
76
|
-
* Hands the live three.js viewer to the host once it mounts, for apps drawing their own
|
|
77
|
-
* content alongside solve results. See `Viewer.svelte` for the contract.
|
|
78
|
-
*/
|
|
70
|
+
/** Hands the live three.js viewer to the host once it mounts. See `Viewer.svelte` for the contract. */
|
|
79
71
|
onViewerReady?: (viewer: ThreeViewer) => void | (() => void);
|
|
72
|
+
/** Viewer chrome and defaults. `backgroundColor` and `showSceneManager` are set by the layout. */
|
|
73
|
+
viewerConfig?: ViewerConfig;
|
|
80
74
|
headerRight?: Snippet;
|
|
81
75
|
// Replaces the built-in header; takes precedence over `headerRight`.
|
|
82
76
|
header?: Snippet;
|
|
83
77
|
// Scopes sessionStorage for external-input values; falls back to definitionKey then schema.id.
|
|
84
78
|
externalScopeKey?: string;
|
|
85
|
-
// Renders client-sourced inputs with presentation === 'slot'
|
|
79
|
+
// Renders client-sourced inputs with presentation === 'slot'.
|
|
86
80
|
clientSlot?: ClientSlot;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
* Provided once here and read by every descendant via locale context.
|
|
90
|
-
* Defaults to English when unset. Does not translate schema-authored labels
|
|
91
|
-
* or Grasshopper-sourced names/metadata.
|
|
92
|
-
*/
|
|
81
|
+
// Language for the app's own chrome, English when unset. Does not translate
|
|
82
|
+
// schema-authored labels or Grasshopper-sourced names/metadata.
|
|
93
83
|
lang?: Locale;
|
|
94
84
|
}
|
|
95
85
|
|
|
@@ -119,38 +109,36 @@
|
|
|
119
109
|
header,
|
|
120
110
|
onReady,
|
|
121
111
|
onViewerReady,
|
|
112
|
+
viewerConfig = {},
|
|
122
113
|
externalScopeKey,
|
|
123
114
|
clientSlot,
|
|
124
115
|
lang
|
|
125
116
|
}: Props = $props();
|
|
126
117
|
|
|
127
|
-
//
|
|
118
|
+
// Reaches InputControl deep in the tree.
|
|
128
119
|
// svelte-ignore state_referenced_locally
|
|
129
120
|
setClientSlot(clientSlot);
|
|
130
121
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// getter is re-read reactively, so switching `lang` updates the chrome live.
|
|
122
|
+
// Resolution: explicit `lang` → host-provided locale → English. Passed as a getter so
|
|
123
|
+
// switching `lang` updates the chrome live.
|
|
134
124
|
const hostLocale = getLocaleContext();
|
|
135
125
|
setLocaleContext(() => lang ?? hostLocale.locale);
|
|
136
126
|
const t = $derived(getLocaleContext().messages);
|
|
137
127
|
|
|
138
128
|
const resolvedScopeKey = $derived(externalScopeKey || definitionKey || schema?.id || '');
|
|
139
129
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// reads the reporter lazily so it can capture the session it's wired into.
|
|
130
|
+
// The session owns the value/lifecycle state machine; the driver is its transport. The
|
|
131
|
+
// session is passed as a getter because the two reference each other.
|
|
143
132
|
// svelte-ignore state_referenced_locally
|
|
144
133
|
const driver = createRequestResponseDriver(onSolve, () => session, {
|
|
145
134
|
solveDeadlineMs,
|
|
146
|
-
// The driver's
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
135
|
+
// The driver's memo caches whole solve results, meshes included, and the viewer disposes
|
|
136
|
+
// what it renders on the next scene update. `@selvajs/solve` keeps meshes opaque, so the
|
|
137
|
+
// three.js clone/dispose rules come from the renderer that owns them. Without this, a memo
|
|
138
|
+
// hit serves an already-disposed mesh.
|
|
150
139
|
meshPolicy,
|
|
151
|
-
// `
|
|
152
|
-
//
|
|
153
|
-
// callback, so it reads `session` after initialization rather than during it.
|
|
140
|
+
// `isSolving` lives on the driver, which the session can't observe — republish so the
|
|
141
|
+
// spinner and disabled states track it.
|
|
154
142
|
onChange: () => session.notify()
|
|
155
143
|
});
|
|
156
144
|
// svelte-ignore state_referenced_locally
|
|
@@ -201,10 +189,8 @@
|
|
|
201
189
|
session.solve();
|
|
202
190
|
}
|
|
203
191
|
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// calls every render to keep the footer in sync. The composable no-ops when component
|
|
207
|
-
// is absent, so the hook itself stays unconditional.
|
|
192
|
+
// Registration is fixed at mount, so the static props are untracked. `footerComponentProps`
|
|
193
|
+
// stays live — the renderer calls it every render to keep the footer in sync.
|
|
208
194
|
useFooterItem({
|
|
209
195
|
id: untrack(() => footerItemId),
|
|
210
196
|
component: untrack(() => footerComponent),
|
|
@@ -257,6 +243,7 @@
|
|
|
257
243
|
values={session.values}
|
|
258
244
|
logoUrl={logo}
|
|
259
245
|
{onViewerReady}
|
|
246
|
+
{viewerConfig}
|
|
260
247
|
{panelActions}
|
|
261
248
|
{showSaveButton}
|
|
262
249
|
{showLoadButton}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
onSaveState?: (state: ParameterPreset) => void | Promise<void>;
|
|
25
25
|
/** When set, the Load dialog lists these states instead of showing a file input. */
|
|
26
26
|
onListStates?: () => ParameterPreset[] | Promise<ParameterPreset[]>;
|
|
27
|
-
/**
|
|
27
|
+
/** Overrides merged over `DEFAULT_PRESET_LABELS`. */
|
|
28
28
|
labels?: Partial<PresetLabels>;
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -42,21 +42,18 @@
|
|
|
42
42
|
|
|
43
43
|
const t = $derived({ ...DEFAULT_PRESET_LABELS, ...labels });
|
|
44
44
|
|
|
45
|
-
// Save dialog state
|
|
46
45
|
let showExportDialog = $state(false);
|
|
47
46
|
let exportName = $state('');
|
|
48
47
|
let exportDescription = $state('');
|
|
49
48
|
let exportAuthor = $state('');
|
|
50
49
|
let exportTags = $state('');
|
|
51
50
|
|
|
52
|
-
// Import/validation state
|
|
53
51
|
let showValidationDialog = $state(false);
|
|
54
52
|
let showLoadDialog = $state(false);
|
|
55
53
|
let loadResult = $state<PresetLoadResult | null>(null);
|
|
56
54
|
let loadingPresetName = $state('');
|
|
57
55
|
let fileInputRef = $state<HTMLInputElement | null>(null);
|
|
58
56
|
|
|
59
|
-
// Listed states (when onListStates is provided)
|
|
60
57
|
let listedStates = $state<ParameterPreset[]>([]);
|
|
61
58
|
let isLoadingList = $state(false);
|
|
62
59
|
let listError = $state('');
|
|
@@ -90,8 +87,8 @@
|
|
|
90
87
|
showExportDialog = false;
|
|
91
88
|
}
|
|
92
89
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
90
|
+
// Shared by every load path: applies clean presets directly, routes any errors or
|
|
91
|
+
// warnings through the validation dialog.
|
|
95
92
|
function tryLoad(preset: ParameterPreset) {
|
|
96
93
|
const result = loadPreset(preset, schema);
|
|
97
94
|
if (result.isValid) {
|
|
@@ -114,7 +111,7 @@
|
|
|
114
111
|
alert(t.loadImportError + (error as Error).message);
|
|
115
112
|
}
|
|
116
113
|
|
|
117
|
-
// Reset
|
|
114
|
+
// Reset so re-picking the same file fires `change` again.
|
|
118
115
|
input.value = '';
|
|
119
116
|
}
|
|
120
117
|
|
|
@@ -298,7 +295,6 @@
|
|
|
298
295
|
|
|
299
296
|
{#if loadResult}
|
|
300
297
|
<div class="gap-4 py-4 grid">
|
|
301
|
-
<!-- Summary Alert -->
|
|
302
298
|
{#if loadResult.isValid}
|
|
303
299
|
<Card.Root class="p-4 border-success/30 bg-success/5">
|
|
304
300
|
<div class="gap-3 flex items-start">
|
|
@@ -341,7 +337,6 @@
|
|
|
341
337
|
</Card.Root>
|
|
342
338
|
{/if}
|
|
343
339
|
|
|
344
|
-
<!-- Issues List -->
|
|
345
340
|
{#if !loadResult.isValid}
|
|
346
341
|
<div class="space-y-2">
|
|
347
342
|
<h4 class="text-sm font-medium">{t.validationIssuesHeading}</h4>
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
import PageFooter from './PageFooter.svelte';
|
|
5
5
|
|
|
6
6
|
interface AppShellProps {
|
|
7
|
-
// Header
|
|
8
7
|
showHeader?: boolean;
|
|
9
8
|
homeUrl?: string;
|
|
10
9
|
title?: string | null;
|
|
@@ -17,21 +16,19 @@
|
|
|
17
16
|
subnav?: Snippet;
|
|
18
17
|
headerClass?: string;
|
|
19
18
|
/**
|
|
20
|
-
*
|
|
21
|
-
* header bar instead of the built-in PageHeader. The bar keeps the
|
|
19
|
+
* Replaces the built-in PageHeader inside the sticky header bar. The bar keeps the
|
|
22
20
|
* standard `--header-h` height so the fixed-mode layout math is unaffected.
|
|
23
21
|
*/
|
|
24
22
|
header?: Snippet;
|
|
25
23
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// 'scroll' —
|
|
24
|
+
// 'fixed' — viewport-locked, no page scroll; body owns its own scroll. For app-like
|
|
25
|
+
// pages (builder, preview, library/[guid]).
|
|
26
|
+
// 'scroll' — normal page flow, footer sticks to the bottom. For dashboard-style pages.
|
|
29
27
|
mode?: 'fixed' | 'scroll';
|
|
30
28
|
|
|
31
|
-
//
|
|
29
|
+
// When present, body becomes a flex row: sidenav | main.
|
|
32
30
|
sidenav?: Snippet;
|
|
33
31
|
|
|
34
|
-
// Footer (only rendered when `showFooter`)
|
|
35
32
|
showFooter?: boolean;
|
|
36
33
|
errors?: string[];
|
|
37
34
|
warnings?: string[];
|
|
@@ -39,7 +36,6 @@
|
|
|
39
36
|
footerText?: string;
|
|
40
37
|
footerChildren?: Snippet;
|
|
41
38
|
|
|
42
|
-
// Body content
|
|
43
39
|
class?: string;
|
|
44
40
|
bodyClass?: string;
|
|
45
41
|
children: Snippet;
|
|
@@ -70,12 +66,8 @@
|
|
|
70
66
|
children
|
|
71
67
|
}: AppShellProps = $props();
|
|
72
68
|
|
|
73
|
-
// Default the footer's copyright owner to the header's brand name when the
|
|
74
|
-
// caller didn't specify one.
|
|
75
69
|
const _copyright = $derived(copyrightName ?? brandName);
|
|
76
70
|
|
|
77
|
-
// fixed: viewport-locked, no page scroll — body owns its own scroll internally.
|
|
78
|
-
// scroll: normal page flow, footer sticks to bottom via sticky positioning.
|
|
79
71
|
const rootClass = $derived(
|
|
80
72
|
_mode === 'fixed'
|
|
81
73
|
? `flex flex-col h-screen overflow-hidden bg-background ${className}`
|
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
import type { Component, Snippet } from 'svelte';
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
|
-
//
|
|
5
|
+
// Renders at h-8 w-8 above the title.
|
|
6
6
|
icon?: Component<{ class?: string }>;
|
|
7
7
|
title: string;
|
|
8
8
|
description?: string;
|
|
9
9
|
// Vertical breathing room. Most pages want `lg`; tighter contexts (drawer, nested card) use `sm`.
|
|
10
10
|
size?: 'sm' | 'md' | 'lg';
|
|
11
11
|
class?: string;
|
|
12
|
-
//
|
|
12
|
+
// Overrides `description`. Use when the text needs rich content like <code>.
|
|
13
13
|
body?: Snippet;
|
|
14
|
-
// Buttons / links rendered below.
|
|
15
14
|
actions?: Snippet;
|
|
16
15
|
}
|
|
17
16
|
|
|
@@ -60,9 +60,7 @@
|
|
|
60
60
|
<footer
|
|
61
61
|
class="px-4 gap-4 text-xs h-7 flex shrink-0 items-center justify-between border-t border-border bg-background text-muted-foreground select-none"
|
|
62
62
|
>
|
|
63
|
-
<!-- Left section: Status items + custom context items -->
|
|
64
63
|
<div class="gap-4 flex items-center">
|
|
65
|
-
<!-- Custom left items from context -->
|
|
66
64
|
{#each leftItems as item (item.id)}
|
|
67
65
|
<FooterItemRenderer {item} />
|
|
68
66
|
{/each}
|
|
@@ -103,9 +101,7 @@
|
|
|
103
101
|
{/if}
|
|
104
102
|
</div>
|
|
105
103
|
|
|
106
|
-
<!-- Right section: Custom items + Copyright -->
|
|
107
104
|
<div class="gap-4 ml-auto flex items-center">
|
|
108
|
-
<!-- Custom right items from context -->
|
|
109
105
|
{#each rightItems as item (item.id)}
|
|
110
106
|
<FooterItemRenderer {item} />
|
|
111
107
|
{/each}
|
|
@@ -47,11 +47,8 @@
|
|
|
47
47
|
// One visibility evaluation per item per render; columnStarts and gridItem both read it.
|
|
48
48
|
const visibilityMap = $derived(buildVisibilityMap(items, values));
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
* for spans and linebreak resets. Items hidden by visibility don't consume
|
|
53
|
-
* a slot (matching what the renderer does — they're skipped entirely).
|
|
54
|
-
*/
|
|
50
|
+
// Hidden items must not consume a slot here, because gridItem skips them entirely —
|
|
51
|
+
// counting them would offset every following item's column.
|
|
55
52
|
const columnStarts = $derived.by(() => {
|
|
56
53
|
const positions: number[] = [];
|
|
57
54
|
let col = 0;
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
const ext = $derived(file ? (file.fileType ?? '').toLowerCase() : '');
|
|
22
22
|
const isSupported = $derived(SUPPORTED_EXTS.has(ext));
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// image-context SVGs
|
|
24
|
+
// Binary formats (PNG/JPG/WEBP/GIF) arrive base64; SVG arrives as plain UTF-8 XML
|
|
25
|
+
// and must be percent-encoded into the data URL.
|
|
26
|
+
// Rendering through <img src> rather than inlining the SVG is a security choice:
|
|
27
|
+
// browsers disable scripting in image-context SVGs, so untrusted SVG cannot
|
|
28
|
+
// execute against the host. Don't switch to {@html} or an inline <svg>.
|
|
28
29
|
const dataUrl = $derived.by(() => {
|
|
29
30
|
if (!file || !isSupported) return null;
|
|
30
31
|
const mime = MIME_BY_EXT[ext] ?? 'application/octet-stream';
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
value?: SupportedTypes;
|
|
35
35
|
displayName?: string;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* `forceSolve` solves even in manual-solve mode. Used for system-initiated changes
|
|
38
|
+
* (e.g. pruning a vanished dynamic-list selection), where leaving the previous output
|
|
39
|
+
* on screen would misrepresent the input that now sits above it.
|
|
40
40
|
*/
|
|
41
41
|
onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
42
42
|
disabled?: boolean;
|
|
@@ -56,9 +56,8 @@
|
|
|
56
56
|
const inputId = $derived(`input-${item.paramId}`);
|
|
57
57
|
const label = $derived(displayName || item.displayName || item.paramId);
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// client slot source means presentation === 'slot'.
|
|
59
|
+
// The 'hidden' presentation never reaches here — visible:false filters it out
|
|
60
|
+
// upstream — so a client-sourced input here always means presentation === 'slot'.
|
|
62
61
|
const isClientSlot = $derived.by(() => {
|
|
63
62
|
const source = (item as { source?: { kind?: string; client?: { presentation?: string } } })
|
|
64
63
|
.source;
|
|
@@ -66,7 +65,7 @@
|
|
|
66
65
|
});
|
|
67
66
|
const clientSlot = getClientSlot();
|
|
68
67
|
|
|
69
|
-
//
|
|
68
|
+
// Min/max hint rendered next to the label. `hideRange` lets the author suppress it.
|
|
70
69
|
const numberRangeHint = $derived.by(() => {
|
|
71
70
|
if (!isNumberWidget(item)) return null;
|
|
72
71
|
const cfg = item.config;
|
|
@@ -81,8 +80,8 @@
|
|
|
81
80
|
|
|
82
81
|
const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
|
|
83
82
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
83
|
+
// Options from the last solve win over the author's seed list, so the control is empty
|
|
84
|
+
// until the first solve unless defaultOptions is set.
|
|
86
85
|
const dynamicListConfig = $derived(
|
|
87
86
|
isDynamicValueListWidget(item)
|
|
88
87
|
? (item.config as DynamicValueListWidgetConfig | undefined)
|
|
@@ -109,22 +108,22 @@
|
|
|
109
108
|
(dynamicListConfig?.emptyBehavior ?? 'hide') === 'hide'
|
|
110
109
|
);
|
|
111
110
|
|
|
112
|
-
// When a dynamic value list recomputes,
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
111
|
+
// When a dynamic value list recomputes, the selected value may no longer be an option.
|
|
112
|
+
// Prune it so the control shows a valid option instead of rendering the orphaned raw
|
|
113
|
+
// value as its own label. The user didn't pick this, so force a solve — otherwise a
|
|
114
|
+
// manual-solve schema keeps the prior output on screen, making it look like the
|
|
115
|
+
// auto-picked option produced it.
|
|
117
116
|
//
|
|
118
|
-
// INVARIANT: a dynamic value list must never dispatch an empty/null value to solve.
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// is deliberately no "user cleared it, stay empty" path.
|
|
117
|
+
// INVARIANT: a dynamic value list must never dispatch an empty/null value to solve. An
|
|
118
|
+
// empty selection reaches the definition as null/"", which throws NREs in downstream
|
|
119
|
+
// geometry components (e.g. Bounding Rectangle) and nulls every output beyond them.
|
|
120
|
+
// There is always at least one option, so every terminal state below resolves to one;
|
|
121
|
+
// there is deliberately no "user cleared it, stay empty" path.
|
|
123
122
|
//
|
|
124
|
-
// LOOP BREAKER: a definition whose computed options DEPEND on the selection can oscillate
|
|
125
|
-
// auto-pick A → solve → new options exclude A → auto-pick B → solve → … Each cycle
|
|
126
|
-
// solves and re-parses
|
|
127
|
-
//
|
|
123
|
+
// LOOP BREAKER: a definition whose computed options DEPEND on the selection can oscillate
|
|
124
|
+
// — auto-pick A → solve → new options exclude A → auto-pick B → solve → … Each cycle
|
|
125
|
+
// force-solves and re-parses the (possibly multi-MB) options, freezing the tab: every
|
|
126
|
+
// solve result blocks the main thread and the loop never yields. Bound consecutive
|
|
128
127
|
// system-initiated picks; any real user commit resets the budget.
|
|
129
128
|
const MAX_CONSECUTIVE_AUTO_PICKS = 3;
|
|
130
129
|
let autoPickCount = 0;
|
|
@@ -133,11 +132,11 @@
|
|
|
133
132
|
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
134
133
|
const validValues = new Set(Object.values(dynamicListOptions));
|
|
135
134
|
const firstOption = Object.values(dynamicListOptions)[0];
|
|
136
|
-
// Already valid → stable; refill the auto-pick budget and stop.
|
|
137
135
|
const isValid = Array.isArray(value)
|
|
138
136
|
? value.length > 0 && value.every((v) => typeof v === 'string' && validValues.has(v))
|
|
139
137
|
: typeof value === 'string' && value !== '' && validValues.has(value);
|
|
140
138
|
if (isValid) {
|
|
139
|
+
// Settled on a valid option — refill the budget.
|
|
141
140
|
autoPickCount = 0;
|
|
142
141
|
return;
|
|
143
142
|
}
|
|
@@ -151,21 +150,21 @@
|
|
|
151
150
|
}
|
|
152
151
|
if (Array.isArray(value)) {
|
|
153
152
|
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
154
|
-
// Empty
|
|
155
|
-
//
|
|
153
|
+
// Empty — never selected or fully pruned — falls back to the first option: a
|
|
154
|
+
// checklist that solves empty produces the same null cascade as a single value.
|
|
156
155
|
if (pruned.length !== value.length || value.length === 0) {
|
|
157
156
|
autoPickCount++;
|
|
158
157
|
onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
|
|
159
158
|
}
|
|
160
159
|
} else if (typeof value !== 'string' || value === '' || !validValues.has(value)) {
|
|
161
|
-
//
|
|
160
|
+
// Same fallback for a single value: never-selected and stale both resolve, never clear.
|
|
162
161
|
autoPickCount++;
|
|
163
162
|
onChange(item.paramId, firstOption, true);
|
|
164
163
|
}
|
|
165
164
|
});
|
|
166
165
|
|
|
167
166
|
function commit(newValue: SupportedTypes) {
|
|
168
|
-
// A real user pick re-arms the
|
|
167
|
+
// A real user pick re-arms the auto-pick budget.
|
|
169
168
|
autoPickCount = 0;
|
|
170
169
|
value = newValue;
|
|
171
170
|
onChange(item.paramId, newValue);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { OutputLayoutItem } from '@selvajs/schemas';
|
|
3
|
-
import type
|
|
3
|
+
import { subFolderSegments, type FileData } from '@selvajs/compute/core';
|
|
4
4
|
import ChartOutput from './ChartOutput.svelte';
|
|
5
5
|
import ImageOutput from './ImageOutput.svelte';
|
|
6
6
|
import {
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
|
|
24
24
|
let { item, value, displayName }: Props = $props();
|
|
25
25
|
|
|
26
|
-
// --- text/number state ---
|
|
27
26
|
let copied = $state(false);
|
|
28
27
|
let copyTimeout: ReturnType<typeof setTimeout>;
|
|
29
28
|
|
|
@@ -47,7 +46,6 @@
|
|
|
47
46
|
}
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
// --- file state ---
|
|
51
49
|
let downloading = $state(false);
|
|
52
50
|
let downloadError = $state<string | null>(null);
|
|
53
51
|
|
|
@@ -57,7 +55,6 @@
|
|
|
57
55
|
const fileCount = $derived(filesArray.length);
|
|
58
56
|
const totalSize = $derived(filesArray.reduce((sum, f) => sum + getBase64FileSize(f.data), 0));
|
|
59
57
|
|
|
60
|
-
// --- folder tree ---
|
|
61
58
|
type TreeNode =
|
|
62
59
|
| { type: 'file'; file: FileData }
|
|
63
60
|
| { type: 'folder'; name: string; children: SvelteMap<string, TreeNode> };
|
|
@@ -66,7 +63,7 @@
|
|
|
66
63
|
const root = new SvelteMap<string, TreeNode>();
|
|
67
64
|
for (let i = 0; i < files.length; i++) {
|
|
68
65
|
const file = files[i];
|
|
69
|
-
const parts = (file.subFolder
|
|
66
|
+
const parts = subFolderSegments(file.subFolder);
|
|
70
67
|
let current = root;
|
|
71
68
|
for (const part of parts) {
|
|
72
69
|
if (!current.has(part)) {
|
|
@@ -98,7 +95,7 @@
|
|
|
98
95
|
const fileTree = $derived(hasSubFolders ? buildTree(filesArray) : null);
|
|
99
96
|
|
|
100
97
|
function fullPath(f: FileData): string {
|
|
101
|
-
const folder = (f.subFolder
|
|
98
|
+
const folder = subFolderSegments(f.subFolder).join('/');
|
|
102
99
|
const name = `${f.fileName}${f.fileType ?? ''}`;
|
|
103
100
|
return folder ? `${folder}/${name}` : name;
|
|
104
101
|
}
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
// Computed value list options keyed by the target input id, derived from solved outputs.
|
|
37
37
|
const dynamicOptions = $derived(buildDynamicValueListOptions(schema, values));
|
|
38
38
|
|
|
39
|
-
// Tab selection
|
|
40
39
|
$effect(() => {
|
|
41
40
|
if (requestedTabId && visibleTabs.some((t) => t.id === requestedTabId)) {
|
|
42
41
|
activeTabId = requestedTabId;
|
|
@@ -45,7 +44,7 @@
|
|
|
45
44
|
}
|
|
46
45
|
});
|
|
47
46
|
|
|
48
|
-
//
|
|
47
|
+
// Seed each group's collapsed state once; re-seeding would discard the user's own toggles.
|
|
49
48
|
$effect(() => {
|
|
50
49
|
if (schema.layout.type !== 'tabbed') return;
|
|
51
50
|
const initial: Record<string, boolean> = {};
|
|
@@ -57,7 +56,8 @@
|
|
|
57
56
|
if (Object.keys(initial).length > 0) Object.assign(collapsedGroups, initial);
|
|
58
57
|
});
|
|
59
58
|
|
|
60
|
-
//
|
|
59
|
+
// A hidden or disabled input still solves, so reset it to its default — otherwise the
|
|
60
|
+
// value the user last set while it was visible keeps feeding the definition.
|
|
61
61
|
$effect(() => {
|
|
62
62
|
if (schema.layout.type !== 'tabbed') return;
|
|
63
63
|
const updates: Record<string, unknown> = {};
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
onChange
|
|
22
22
|
}: Props = $props();
|
|
23
23
|
|
|
24
|
-
// Resolve which modes are actually available
|
|
25
24
|
let effectiveModes = $derived(
|
|
26
25
|
allowedInputModes && allowedInputModes.length > 0
|
|
27
26
|
? allowedInputModes
|
|
@@ -29,7 +28,6 @@
|
|
|
29
28
|
);
|
|
30
29
|
let showToggle = $derived(effectiveModes.length > 1);
|
|
31
30
|
|
|
32
|
-
// Active mode: default to defaultInputMode if allowed, otherwise first allowed mode
|
|
33
31
|
let activeMode = $state<'upload' | 'url'>('upload');
|
|
34
32
|
$effect(() => {
|
|
35
33
|
const preferred = defaultInputMode ?? 'upload';
|
|
@@ -44,7 +42,7 @@
|
|
|
44
42
|
let urlError = $state<{ message: string; isCors: boolean; hint?: string } | null>(null);
|
|
45
43
|
let urlSuccess = $state('');
|
|
46
44
|
|
|
47
|
-
//
|
|
45
|
+
// The committed value is a JSON envelope; rebuild the display state from it.
|
|
48
46
|
$effect(() => {
|
|
49
47
|
if (value) {
|
|
50
48
|
try {
|
|
@@ -79,8 +77,7 @@
|
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
function isCorsError(error: unknown): boolean {
|
|
82
|
-
//
|
|
83
|
-
// because the browser blocks the response entirely
|
|
80
|
+
// The browser blocks the response entirely, so this is all we get — no status code.
|
|
84
81
|
if (error instanceof TypeError && error.message === 'Failed to fetch') return true;
|
|
85
82
|
return false;
|
|
86
83
|
}
|
|
@@ -158,9 +155,8 @@
|
|
|
158
155
|
return '';
|
|
159
156
|
}
|
|
160
157
|
})();
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// Give a message that covers both cases.
|
|
158
|
+
// The opaque TypeError hides CORS blocks and plain network failures (404, DNS)
|
|
159
|
+
// alike, so the message has to cover both.
|
|
164
160
|
let hint =
|
|
165
161
|
'Check that the URL is correct and the file is publicly accessible. If the server requires login, download the file and use the Upload option instead.';
|
|
166
162
|
if (host.includes('sharepoint.com') || host.includes('onedrive.com')) {
|
|
@@ -201,10 +197,11 @@
|
|
|
201
197
|
return;
|
|
202
198
|
}
|
|
203
199
|
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
200
|
+
// The file is base64-embedded into the compute request body, so without a
|
|
201
|
+
// client-side cap an oversize file comes back as an opaque 413. This cap is
|
|
202
|
+
// currently LOOSER than the server's COMPUTE_REQUEST_MAX_BYTES (see the TEMP
|
|
203
|
+
// note in constants.ts) — some files pass here and still 413. The URL import
|
|
204
|
+
// path applies the same cap.
|
|
208
205
|
if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
209
206
|
alert(
|
|
210
207
|
`File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
|
|
@@ -15,13 +15,11 @@
|
|
|
15
15
|
} = $props();
|
|
16
16
|
|
|
17
17
|
const hasContent = $derived.by(() => {
|
|
18
|
-
// has slotted error
|
|
19
18
|
if (children) return true;
|
|
20
19
|
|
|
21
|
-
// no errors
|
|
22
20
|
if (!errors || errors.length === 0) return false;
|
|
23
21
|
|
|
24
|
-
//
|
|
22
|
+
// A lone error with no message renders an empty alert box — suppress it.
|
|
25
23
|
if (errors.length === 1 && !errors[0]?.message) {
|
|
26
24
|
return false;
|
|
27
25
|
}
|