@selvajs/ui 6.6.0-beta.8 → 6.6.0-beta.9
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 +37 -7
- package/dist/components/compute/AppLayout.svelte.d.ts +5 -0
- package/dist/components/compute/ParameterPresetManager.svelte +8 -20
- package/dist/components/compute/SolveMessages.svelte +26 -3
- package/dist/components/compute/SolveMessages.svelte.d.ts +7 -0
- package/dist/components/layout/PageFooter.svelte +3 -1
- package/dist/components/layout/SideNav.svelte +55 -14
- package/dist/components/preview/ChartOutput.svelte +18 -13
- package/dist/components/preview/Group.svelte +6 -1
- package/dist/components/preview/ImageOutput.svelte +3 -1
- package/dist/components/preview/InputControl.svelte +108 -44
- package/dist/components/preview/InputControl.svelte.d.ts +5 -1
- package/dist/components/preview/TabContent.svelte +61 -29
- package/dist/components/preview/TabContent.svelte.d.ts +5 -1
- package/dist/components/preview/TabLayout.svelte +25 -3
- package/dist/components/preview/TabLayout.svelte.d.ts +5 -0
- package/dist/components/preview/inputs/ChecklistInput.svelte +7 -2
- package/dist/components/preview/inputs/ChecklistInput.svelte.d.ts +2 -1
- package/dist/components/preview/inputs/DropdownInput.svelte +16 -4
- package/dist/components/preview/inputs/DropdownInput.svelte.d.ts +2 -1
- package/dist/components/preview/inputs/FileInput.svelte +56 -65
- package/dist/components/preview/inputs/GeometryInput.svelte +128 -0
- package/dist/components/preview/inputs/GeometryInput.svelte.d.ts +21 -0
- package/dist/components/preview/inputs/OptionGlyph.svelte +29 -0
- package/dist/components/preview/inputs/OptionGlyph.svelte.d.ts +8 -0
- package/dist/components/preview/inputs/SegmentedInput.svelte +44 -0
- package/dist/components/preview/inputs/SegmentedInput.svelte.d.ts +12 -0
- package/dist/components/preview/inputs/TileInput.svelte +80 -0
- package/dist/components/preview/inputs/TileInput.svelte.d.ts +12 -0
- package/dist/components/preview/inputs/index.d.ts +3 -0
- package/dist/components/preview/inputs/index.js +3 -0
- package/dist/components/primitives/SolvingIndicator.svelte +28 -3
- package/dist/components/primitives/SolvingIndicator.svelte.d.ts +7 -0
- package/dist/components/primitives/button/button.svelte +3 -0
- package/dist/components/primitives/button/button.svelte.d.ts +3 -0
- package/dist/components/primitives/toggle/toggle.svelte +3 -1
- package/dist/components/primitives/toggle/toggle.svelte.d.ts +3 -0
- package/dist/components/viewer/Viewer.svelte +11 -11
- package/dist/compute/solving.svelte.d.ts +7 -1
- package/dist/compute/solving.svelte.js +12 -1
- package/dist/contexts/hostContext.svelte.d.ts +39 -0
- package/dist/contexts/hostContext.svelte.js +9 -0
- package/dist/i18n/messages.d.ts +33 -0
- package/dist/i18n/messages.js +58 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/schema/host-support.d.ts +29 -0
- package/dist/schema/host-support.js +97 -0
- package/dist/schema/option-display.d.ts +12 -0
- package/dist/schema/option-display.js +29 -0
- package/dist/schema/param-exporter.d.ts +4 -1
- package/dist/schema/param-exporter.js +10 -6
- package/dist/styles/base.css +6 -3
- package/dist/utils/file-download.js +6 -10
- package/dist/utils/host-files.d.ts +12 -0
- package/dist/utils/host-files.js +46 -0
- package/dist/utils/webp-to-png.d.ts +10 -0
- package/dist/utils/webp-to-png.js +41 -0
- package/package.json +9 -9
- package/src/lib/components/compute/AppLayout.svelte +37 -7
- package/src/lib/components/compute/ParameterPresetManager.svelte +8 -20
- package/src/lib/components/compute/SolveMessages.svelte +26 -3
- package/src/lib/components/layout/PageFooter.svelte +3 -1
- package/src/lib/components/layout/SideNav.svelte +55 -14
- package/src/lib/components/preview/ChartOutput.svelte +18 -13
- package/src/lib/components/preview/Group.svelte +6 -1
- package/src/lib/components/preview/ImageOutput.svelte +3 -1
- package/src/lib/components/preview/InputControl.svelte +108 -44
- package/src/lib/components/preview/TabContent.svelte +61 -29
- package/src/lib/components/preview/TabLayout.svelte +25 -3
- package/src/lib/components/preview/inputs/ChecklistInput.svelte +7 -2
- package/src/lib/components/preview/inputs/DropdownInput.svelte +16 -4
- package/src/lib/components/preview/inputs/FileInput.svelte +56 -65
- package/src/lib/components/preview/inputs/GeometryInput.svelte +128 -0
- package/src/lib/components/preview/inputs/OptionGlyph.svelte +29 -0
- package/src/lib/components/preview/inputs/SegmentedInput.svelte +44 -0
- package/src/lib/components/preview/inputs/TileInput.svelte +80 -0
- package/src/lib/components/preview/inputs/index.ts +3 -0
- package/src/lib/components/primitives/SolvingIndicator.svelte +28 -3
- package/src/lib/components/primitives/button/button.svelte +3 -0
- package/src/lib/components/primitives/toggle/toggle.svelte +3 -1
- package/src/lib/components/viewer/Viewer.svelte +11 -11
- package/src/lib/compute/solving.svelte.ts +16 -1
- package/src/lib/contexts/hostContext.svelte.ts +51 -0
- package/src/lib/i18n/messages.ts +96 -0
- package/src/lib/index.ts +20 -0
- package/src/lib/schema/host-support.ts +129 -0
- package/src/lib/schema/option-display.ts +44 -0
- package/src/lib/schema/param-exporter.ts +10 -7
- package/src/lib/styles/base.css +6 -3
- package/src/lib/utils/file-download.ts +7 -11
- package/src/lib/utils/host-files.ts +61 -0
- package/src/lib/utils/webp-to-png.ts +48 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
FileInputWidgetConfig,
|
|
5
5
|
DynamicValueListWidgetConfig,
|
|
6
6
|
DropdownWidgetConfig,
|
|
7
|
+
UISchema,
|
|
7
8
|
SupportedTypes
|
|
8
9
|
} from '@selvajs/schemas';
|
|
9
10
|
import {
|
|
@@ -16,6 +17,8 @@
|
|
|
16
17
|
isColorWidget
|
|
17
18
|
} from '@selvajs/schemas';
|
|
18
19
|
import * as Field from '$lib/components/primitives/field';
|
|
20
|
+
import { Button } from '$lib/components/primitives/button';
|
|
21
|
+
import { toast } from '$lib/components/primitives/sonner';
|
|
19
22
|
import HelpButton from './HelpButton.svelte';
|
|
20
23
|
import {
|
|
21
24
|
CheckboxInput,
|
|
@@ -23,11 +26,21 @@
|
|
|
23
26
|
ColorInput,
|
|
24
27
|
DropdownInput,
|
|
25
28
|
FileInput,
|
|
29
|
+
GeometryInput,
|
|
26
30
|
NumberInput,
|
|
27
|
-
|
|
31
|
+
SegmentedInput,
|
|
32
|
+
TextInput,
|
|
33
|
+
TileInput
|
|
28
34
|
} from '$lib/components/preview/inputs';
|
|
29
35
|
import { getClientSlot } from '$lib/contexts/clientSlotContext.svelte';
|
|
36
|
+
import {
|
|
37
|
+
getHostContext,
|
|
38
|
+
type GeometryPickTarget,
|
|
39
|
+
type PickKind
|
|
40
|
+
} from '$lib/contexts/hostContext.svelte';
|
|
30
41
|
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
42
|
+
import { isGeometryParamType, isTriggerItem } from '$lib/schema/host-support';
|
|
43
|
+
import { resolveOptionDisplay } from '$lib/schema/option-display';
|
|
31
44
|
|
|
32
45
|
interface Props {
|
|
33
46
|
item: InputLayoutItem;
|
|
@@ -42,6 +55,10 @@
|
|
|
42
55
|
disabled?: boolean;
|
|
43
56
|
/** Runtime-computed options for a dynamic value list input (name -> value). */
|
|
44
57
|
dynamicOptions?: Record<string, string>;
|
|
58
|
+
/** The input's paramType: a geometry input gets its control from this, not from the widget type. */
|
|
59
|
+
paramType?: string;
|
|
60
|
+
/** The schema's icons and thumbnails, which value-list options point into. */
|
|
61
|
+
assets?: UISchema['assets'];
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
let {
|
|
@@ -50,10 +67,25 @@
|
|
|
50
67
|
displayName,
|
|
51
68
|
onChange,
|
|
52
69
|
disabled = false,
|
|
53
|
-
dynamicOptions
|
|
70
|
+
dynamicOptions,
|
|
71
|
+
paramType,
|
|
72
|
+
assets
|
|
54
73
|
}: Props = $props();
|
|
55
74
|
|
|
56
75
|
const locale = getLocaleContext();
|
|
76
|
+
const hostActions = getHostContext();
|
|
77
|
+
const { pickGeometry, clearGeometry, onGeometrySummary } = hostActions;
|
|
78
|
+
const isGeometry = $derived(isGeometryParamType(paramType));
|
|
79
|
+
const isTrigger = $derived(isTriggerItem(item));
|
|
80
|
+
const pickTarget = $derived(
|
|
81
|
+
isGeometry ? hostActions.describeGeometry?.(item.paramId) : undefined
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Get Point/Line/Plane take their own kind; Get Geometry takes what its filter allows.
|
|
85
|
+
function pickKinds(target: GeometryPickTarget | undefined): readonly PickKind[] | undefined {
|
|
86
|
+
if (paramType === 'point' || paramType === 'line' || paramType === 'plane') return [paramType];
|
|
87
|
+
return target?.kinds;
|
|
88
|
+
}
|
|
57
89
|
|
|
58
90
|
const inputId = $derived(`input-${item.paramId}`);
|
|
59
91
|
const label = $derived(displayName || item.displayName || item.paramId);
|
|
@@ -102,7 +134,8 @@
|
|
|
102
134
|
// As a dropdown config so DropdownInput/ChecklistInput can consume it unchanged.
|
|
103
135
|
const dynamicListAsDropdownConfig = $derived<DropdownWidgetConfig>({
|
|
104
136
|
options: dynamicListOptions,
|
|
105
|
-
displayAs: dynamicListConfig?.displayAs ?? 'dropdown'
|
|
137
|
+
displayAs: dynamicListConfig?.displayAs ?? 'dropdown',
|
|
138
|
+
optionMedia: dynamicListConfig?.optionMedia
|
|
106
139
|
});
|
|
107
140
|
const hideDynamicListWhenEmpty = $derived(
|
|
108
141
|
isDynamicValueListWidget(item) &&
|
|
@@ -173,6 +206,48 @@
|
|
|
173
206
|
}
|
|
174
207
|
</script>
|
|
175
208
|
|
|
209
|
+
{#snippet valueList(config: DropdownWidgetConfig)}
|
|
210
|
+
{@const display = resolveOptionDisplay(
|
|
211
|
+
config.displayAs,
|
|
212
|
+
Object.keys(config.options ?? {}).length
|
|
213
|
+
)}
|
|
214
|
+
{@const single = typeof value === 'string' ? value : ''}
|
|
215
|
+
{#if display === 'checklist'}
|
|
216
|
+
<ChecklistInput
|
|
217
|
+
{inputId}
|
|
218
|
+
value={Array.isArray(value)
|
|
219
|
+
? (value as string[])
|
|
220
|
+
: typeof value === 'string' && value
|
|
221
|
+
? [value]
|
|
222
|
+
: []}
|
|
223
|
+
{config}
|
|
224
|
+
{assets}
|
|
225
|
+
onChange={commit}
|
|
226
|
+
{disabled}
|
|
227
|
+
/>
|
|
228
|
+
{:else if display === 'segmented'}
|
|
229
|
+
<SegmentedInput
|
|
230
|
+
labelId="{inputId}-label"
|
|
231
|
+
value={single}
|
|
232
|
+
{config}
|
|
233
|
+
{assets}
|
|
234
|
+
onChange={commit}
|
|
235
|
+
{disabled}
|
|
236
|
+
/>
|
|
237
|
+
{:else if display === 'tiles'}
|
|
238
|
+
<TileInput
|
|
239
|
+
labelId="{inputId}-label"
|
|
240
|
+
value={single}
|
|
241
|
+
{config}
|
|
242
|
+
{assets}
|
|
243
|
+
onChange={commit}
|
|
244
|
+
{disabled}
|
|
245
|
+
/>
|
|
246
|
+
{:else}
|
|
247
|
+
<DropdownInput value={single} {config} {assets} onChange={commit} {disabled} />
|
|
248
|
+
{/if}
|
|
249
|
+
{/snippet}
|
|
250
|
+
|
|
176
251
|
{#snippet labelText(forId?: string, tone: 'quiet' | 'strong' = 'quiet')}
|
|
177
252
|
<Field.Label
|
|
178
253
|
for={forId}
|
|
@@ -218,6 +293,34 @@
|
|
|
218
293
|
})}
|
|
219
294
|
</div>
|
|
220
295
|
{/if}
|
|
296
|
+
{:else if isGeometry}
|
|
297
|
+
<div role="group" class="group/field gap-1.5 min-w-0 flex flex-col">
|
|
298
|
+
{@render fieldHeader(inputId)}
|
|
299
|
+
<GeometryInput
|
|
300
|
+
{inputId}
|
|
301
|
+
kinds={pickKinds(pickTarget)}
|
|
302
|
+
atMost={pickTarget?.atMost}
|
|
303
|
+
summary={typeof value === 'string' ? value : undefined}
|
|
304
|
+
onPick={pickGeometry && (() => pickGeometry(item.paramId))}
|
|
305
|
+
onClear={clearGeometry && (() => clearGeometry(item.paramId))}
|
|
306
|
+
watchSummary={onGeometrySummary && ((listener) => onGeometrySummary(item.paramId, listener))}
|
|
307
|
+
onError={(message) => toast.error(message)}
|
|
308
|
+
{disabled}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
{:else if isTrigger}
|
|
312
|
+
<div role="group" class="group/field gap-1.5 min-w-0 flex items-center" data-control-line>
|
|
313
|
+
<Button
|
|
314
|
+
id={inputId}
|
|
315
|
+
variant="outline"
|
|
316
|
+
class="min-w-0 flex-1"
|
|
317
|
+
onclick={() => hostActions.trigger?.(item.paramId)}
|
|
318
|
+
disabled={disabled || !hostActions.trigger || hostActions.isSolving?.()}
|
|
319
|
+
>
|
|
320
|
+
<span class="truncate">{label}</span>
|
|
321
|
+
</Button>
|
|
322
|
+
{@render helpButton()}
|
|
323
|
+
</div>
|
|
221
324
|
{:else if !hideDynamicListWhenEmpty}
|
|
222
325
|
<div role="group" class="group/field gap-1.5 min-w-0 flex flex-col">
|
|
223
326
|
{#if !isCheckboxWidget(item)}
|
|
@@ -254,49 +357,10 @@
|
|
|
254
357
|
{disabled}
|
|
255
358
|
/>
|
|
256
359
|
{:else if isDropdownWidget(item)}
|
|
257
|
-
{@
|
|
258
|
-
{#if config.displayAs === 'checklist'}
|
|
259
|
-
<ChecklistInput
|
|
260
|
-
{inputId}
|
|
261
|
-
value={Array.isArray(value)
|
|
262
|
-
? (value as string[])
|
|
263
|
-
: typeof value === 'string' && value
|
|
264
|
-
? [value]
|
|
265
|
-
: []}
|
|
266
|
-
{config}
|
|
267
|
-
onChange={commit}
|
|
268
|
-
{disabled}
|
|
269
|
-
/>
|
|
270
|
-
{:else}
|
|
271
|
-
<DropdownInput
|
|
272
|
-
value={typeof value === 'string' ? value : ''}
|
|
273
|
-
{config}
|
|
274
|
-
onChange={commit}
|
|
275
|
-
{disabled}
|
|
276
|
-
/>
|
|
277
|
-
{/if}
|
|
360
|
+
{@render valueList(item.config)}
|
|
278
361
|
{:else if isDynamicValueListWidget(item)}
|
|
279
362
|
{#if dynamicListHasOptions}
|
|
280
|
-
{
|
|
281
|
-
<ChecklistInput
|
|
282
|
-
{inputId}
|
|
283
|
-
value={Array.isArray(value)
|
|
284
|
-
? (value as string[])
|
|
285
|
-
: typeof value === 'string' && value
|
|
286
|
-
? [value]
|
|
287
|
-
: []}
|
|
288
|
-
config={dynamicListAsDropdownConfig}
|
|
289
|
-
onChange={commit}
|
|
290
|
-
{disabled}
|
|
291
|
-
/>
|
|
292
|
-
{:else}
|
|
293
|
-
<DropdownInput
|
|
294
|
-
value={typeof value === 'string' ? value : ''}
|
|
295
|
-
config={dynamicListAsDropdownConfig}
|
|
296
|
-
onChange={commit}
|
|
297
|
-
{disabled}
|
|
298
|
-
/>
|
|
299
|
-
{/if}
|
|
363
|
+
{@render valueList(dynamicListAsDropdownConfig)}
|
|
300
364
|
{:else}
|
|
301
365
|
<p class="text-sm text-muted-foreground">{locale.messages.noOptionsYet}</p>
|
|
302
366
|
{/if}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
TabConfig,
|
|
4
4
|
SchemaInput,
|
|
5
5
|
DiscoveredOutput,
|
|
6
|
+
UISchema,
|
|
6
7
|
SupportedTypes,
|
|
7
8
|
InputLayoutItem,
|
|
8
9
|
OutputLayoutItem
|
|
@@ -14,6 +15,8 @@
|
|
|
14
15
|
import InputControl from './InputControl.svelte';
|
|
15
16
|
import OutputDisplay from './OutputDisplay.svelte';
|
|
16
17
|
import { evaluateGroupVisibility } from '$lib/schema/visibility-rules';
|
|
18
|
+
import { isSupportedOn, layoutItemKind } from '$lib/schema/host-support';
|
|
19
|
+
import { getHostContext } from '$lib/contexts/hostContext.svelte';
|
|
17
20
|
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
18
21
|
|
|
19
22
|
const locale = getLocaleContext();
|
|
@@ -29,6 +32,10 @@
|
|
|
29
32
|
outputs: DiscoveredOutput[];
|
|
30
33
|
/** Computed value list options keyed by the target dynamic-value-list input id. */
|
|
31
34
|
dynamicOptions?: Record<string, Record<string, string>>;
|
|
35
|
+
/** The schema's icons and thumbnails, which value-list options point into. */
|
|
36
|
+
assets?: UISchema['assets'];
|
|
37
|
+
/** No tinted backdrop and no scroll area of its own: the container scrolls. */
|
|
38
|
+
bare?: boolean;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
let {
|
|
@@ -39,11 +46,24 @@
|
|
|
39
46
|
onValueChange,
|
|
40
47
|
inputs,
|
|
41
48
|
outputs,
|
|
42
|
-
dynamicOptions = {}
|
|
49
|
+
dynamicOptions = {},
|
|
50
|
+
assets,
|
|
51
|
+
bare = false
|
|
43
52
|
}: Props = $props();
|
|
44
53
|
|
|
54
|
+
const { host } = getHostContext();
|
|
55
|
+
|
|
56
|
+
// An element this host can't render is left out, not shown broken: its input still solves with
|
|
57
|
+
// the definition's own default. A group left with nothing to show goes too.
|
|
45
58
|
const visibleGroups = $derived(
|
|
46
|
-
tab.groups
|
|
59
|
+
tab.groups
|
|
60
|
+
.filter((group) => evaluateGroupVisibility(group, values))
|
|
61
|
+
.flatMap((group) => {
|
|
62
|
+
const items = group.items.filter((item) =>
|
|
63
|
+
isSupportedOn(host, layoutItemKind(item, inputs))
|
|
64
|
+
);
|
|
65
|
+
return items.length > 0 || group.items.length === 0 ? [{ ...group, items }] : [];
|
|
66
|
+
})
|
|
47
67
|
);
|
|
48
68
|
|
|
49
69
|
function getInputById(paramId: string): SchemaInput | undefined {
|
|
@@ -68,6 +88,8 @@
|
|
|
68
88
|
onChange={onValueChange}
|
|
69
89
|
disabled={visibility.disabled}
|
|
70
90
|
dynamicOptions={dynamicOptions[input.id]}
|
|
91
|
+
paramType={input.paramType}
|
|
92
|
+
{assets}
|
|
71
93
|
/>
|
|
72
94
|
{/if}
|
|
73
95
|
{/snippet}
|
|
@@ -83,34 +105,44 @@
|
|
|
83
105
|
{/if}
|
|
84
106
|
{/snippet}
|
|
85
107
|
|
|
86
|
-
|
|
87
|
-
<div class=
|
|
88
|
-
|
|
89
|
-
<div class="px-3">
|
|
90
|
-
<!-- A heading per panel lets a screen reader jump between them; the tab, when shown,
|
|
108
|
+
{#snippet body()}
|
|
109
|
+
<div class={bare ? 'px-1' : 'px-3'}>
|
|
110
|
+
<!-- A heading per panel lets a screen reader jump between them; the tab, when shown,
|
|
91
111
|
already names it on screen. -->
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
</div>
|
|
112
|
-
{/if}
|
|
112
|
+
<h2 class="sr-only">{tab.label}</h2>
|
|
113
|
+
{#if visibleGroups.length === 0}
|
|
114
|
+
<StateDisplay type="empty" size="medium" message={t.tabNoGroups} />
|
|
115
|
+
{:else}
|
|
116
|
+
<div class="gap-3 py-3 flex flex-col">
|
|
117
|
+
{#each visibleGroups as group (group.id)}
|
|
118
|
+
<Group
|
|
119
|
+
label={group.label}
|
|
120
|
+
description={group.description}
|
|
121
|
+
items={group.items}
|
|
122
|
+
columns={group.columns ?? 1}
|
|
123
|
+
collapsed={collapsedGroups[group.id] ?? false}
|
|
124
|
+
{values}
|
|
125
|
+
onToggle={() => onToggleGroup(group.id)}
|
|
126
|
+
inputSnippet={renderInput}
|
|
127
|
+
outputSnippet={renderOutput}
|
|
128
|
+
flat={visibleGroups.length === 1}
|
|
129
|
+
/>
|
|
130
|
+
{/each}
|
|
113
131
|
</div>
|
|
114
|
-
|
|
132
|
+
{/if}
|
|
115
133
|
</div>
|
|
134
|
+
{/snippet}
|
|
135
|
+
|
|
136
|
+
<Tabs.Content value={tab.id} class="min-h-0">
|
|
137
|
+
{#if bare}
|
|
138
|
+
{@render body()}
|
|
139
|
+
{:else}
|
|
140
|
+
<div class="h-full bg-muted dark:bg-background">
|
|
141
|
+
<!-- `auto`, not the default `hover`: a hover-only bar comes and goes with the pointer,
|
|
142
|
+
and a host that freezes mid-solve drops the pointer state with it. -->
|
|
143
|
+
<ScrollArea class="h-full" orientation="vertical" type="auto">
|
|
144
|
+
{@render body()}
|
|
145
|
+
</ScrollArea>
|
|
146
|
+
</div>
|
|
147
|
+
{/if}
|
|
116
148
|
</Tabs.Content>
|
|
@@ -15,9 +15,21 @@
|
|
|
15
15
|
panelFilter?: 'left' | 'right';
|
|
16
16
|
/** Externally request a specific tab to be active (e.g. from collapsed strip click) */
|
|
17
17
|
requestedTabId?: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Drops the card frame and the tab's own scroll area, for a container that already frames
|
|
20
|
+
* and scrolls it (the phone drawer). Nested, the inner scroll gets only a sliver of height.
|
|
21
|
+
*/
|
|
22
|
+
bare?: boolean;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
let {
|
|
25
|
+
let {
|
|
26
|
+
schema,
|
|
27
|
+
values,
|
|
28
|
+
onValueChange,
|
|
29
|
+
panelFilter,
|
|
30
|
+
requestedTabId = null,
|
|
31
|
+
bare = false
|
|
32
|
+
}: Props = $props();
|
|
21
33
|
|
|
22
34
|
let activeTabId = $state('');
|
|
23
35
|
let collapsedGroups = $state<Record<string, boolean>>({});
|
|
@@ -66,7 +78,7 @@
|
|
|
66
78
|
}
|
|
67
79
|
</script>
|
|
68
80
|
|
|
69
|
-
|
|
81
|
+
{#snippet tabs()}
|
|
70
82
|
<Tabs.Root value={activeTabId} variant="line" class="min-h-0 flex-1">
|
|
71
83
|
{#if showTabBar}
|
|
72
84
|
<TabBar tabs={visibleTabs} {activeTabId} onTabChange={handleTabChange} />
|
|
@@ -82,7 +94,17 @@
|
|
|
82
94
|
inputs={schema.inputs}
|
|
83
95
|
outputs={schema.outputs}
|
|
84
96
|
{dynamicOptions}
|
|
97
|
+
assets={schema.assets}
|
|
98
|
+
{bare}
|
|
85
99
|
/>
|
|
86
100
|
{/each}
|
|
87
101
|
</Tabs.Root>
|
|
88
|
-
|
|
102
|
+
{/snippet}
|
|
103
|
+
|
|
104
|
+
{#if bare}
|
|
105
|
+
{@render tabs()}
|
|
106
|
+
{:else}
|
|
107
|
+
<Card.Root variant="flush" class="min-h-0 mx-1 flex-1">
|
|
108
|
+
{@render tabs()}
|
|
109
|
+
</Card.Root>
|
|
110
|
+
{/if}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type { DropdownWidgetConfig, SupportedTypes } from '@selvajs/schemas';
|
|
2
|
+
import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
|
|
3
3
|
import { Checkbox } from '$lib/components/primitives/checkbox';
|
|
4
|
+
import { optionGlyph } from '$lib/schema/option-display';
|
|
5
|
+
import OptionGlyph from './OptionGlyph.svelte';
|
|
4
6
|
import { SvelteSet } from 'svelte/reactivity';
|
|
5
7
|
|
|
6
8
|
interface Props {
|
|
7
9
|
inputId: string;
|
|
8
10
|
value?: string[];
|
|
9
11
|
config?: DropdownWidgetConfig;
|
|
12
|
+
assets?: UISchema['assets'];
|
|
10
13
|
onChange: (value: SupportedTypes) => void;
|
|
11
14
|
disabled?: boolean;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
let { inputId, value, config, onChange, disabled = false }: Props = $props();
|
|
17
|
+
let { inputId, value, config, assets, onChange, disabled = false }: Props = $props();
|
|
15
18
|
|
|
16
19
|
const options = $derived(config?.options ?? {});
|
|
17
20
|
const selected = $derived(new Set(value ?? []));
|
|
@@ -32,6 +35,7 @@
|
|
|
32
35
|
{@const optionValue = expr ?? name}
|
|
33
36
|
{@const optionId = `${inputId}-${optionValue}`}
|
|
34
37
|
{@const isSelected = selected.has(optionValue)}
|
|
38
|
+
{@const glyph = optionGlyph(config?.optionMedia?.[name], assets)}
|
|
35
39
|
<label
|
|
36
40
|
for={optionId}
|
|
37
41
|
class="gap-3 px-3 py-2 text-sm flex items-center transition-colors select-none hover:bg-accent/50 {isSelected
|
|
@@ -44,6 +48,7 @@
|
|
|
44
48
|
onCheckedChange={(state) => toggle(optionValue, state === true)}
|
|
45
49
|
{disabled}
|
|
46
50
|
/>
|
|
51
|
+
{#if glyph}<OptionGlyph {glyph} class="text-muted-foreground" />{/if}
|
|
47
52
|
<span class="flex-1" class:font-medium={isSelected}>{name}</span>
|
|
48
53
|
</label>
|
|
49
54
|
{/each}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type { DropdownWidgetConfig, SupportedTypes } from '@selvajs/schemas';
|
|
2
|
+
import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
|
|
3
3
|
import * as Select from '$lib/components/primitives/select';
|
|
4
|
+
import { optionGlyph } from '$lib/schema/option-display';
|
|
5
|
+
import OptionGlyph from './OptionGlyph.svelte';
|
|
4
6
|
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
5
7
|
|
|
6
8
|
interface Props {
|
|
7
9
|
value?: string;
|
|
8
10
|
config?: DropdownWidgetConfig;
|
|
11
|
+
assets?: UISchema['assets'];
|
|
9
12
|
onChange: (value: SupportedTypes) => void;
|
|
10
13
|
disabled?: boolean;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
let { value, config, onChange, disabled = false }: Props = $props();
|
|
16
|
+
let { value, config, assets, onChange, disabled = false }: Props = $props();
|
|
14
17
|
|
|
15
18
|
const locale = getLocaleContext();
|
|
16
19
|
|
|
@@ -19,6 +22,8 @@
|
|
|
19
22
|
const currentLabel = $derived(
|
|
20
23
|
Object.entries(options).find(([_, expr]) => expr === currentValue)?.[0] ?? currentValue
|
|
21
24
|
);
|
|
25
|
+
const glyphOf = (name: string) => optionGlyph(config?.optionMedia?.[name], assets);
|
|
26
|
+
const currentGlyph = $derived(glyphOf(currentLabel));
|
|
22
27
|
</script>
|
|
23
28
|
|
|
24
29
|
<Select.Root
|
|
@@ -33,11 +38,18 @@
|
|
|
33
38
|
{disabled}
|
|
34
39
|
>
|
|
35
40
|
<Select.Trigger class="w-full" {disabled}>
|
|
36
|
-
|
|
41
|
+
<span class="gap-2 min-w-0 flex items-center">
|
|
42
|
+
{#if currentGlyph}<OptionGlyph glyph={currentGlyph} />{/if}
|
|
43
|
+
<span class="truncate">{currentLabel || locale.messages.selectOption}</span>
|
|
44
|
+
</span>
|
|
37
45
|
</Select.Trigger>
|
|
38
46
|
<Select.Content>
|
|
39
47
|
{#each Object.entries(options) as [name, expr] (name)}
|
|
40
|
-
|
|
48
|
+
{@const glyph = glyphOf(name)}
|
|
49
|
+
<Select.Item value={expr ?? name} label={name}>
|
|
50
|
+
{#if glyph}<OptionGlyph {glyph} />{/if}
|
|
51
|
+
{name}
|
|
52
|
+
</Select.Item>
|
|
41
53
|
{/each}
|
|
42
54
|
</Select.Content>
|
|
43
55
|
</Select.Root>
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { APP_DEFAULTS } from '$lib/constants';
|
|
8
8
|
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
9
9
|
import { formatMessage } from '$lib/i18n/messages';
|
|
10
|
+
import { pickFile } from '$lib/utils/host-files';
|
|
11
|
+
import { acceptsFileEnding, isWebp, shouldConvertWebp, webpToPng } from '$lib/utils/webp-to-png';
|
|
10
12
|
|
|
11
13
|
interface Props {
|
|
12
14
|
value?: string;
|
|
@@ -40,7 +42,6 @@
|
|
|
40
42
|
activeMode = effectiveModes.includes(preferred) ? preferred : effectiveModes[0];
|
|
41
43
|
});
|
|
42
44
|
|
|
43
|
-
let fileInput: HTMLInputElement | null = $state(null);
|
|
44
45
|
let uploadedFileName = $state('');
|
|
45
46
|
let urlInput = $state('');
|
|
46
47
|
let isDragging = $state(false);
|
|
@@ -94,9 +95,27 @@
|
|
|
94
95
|
return ext.toLowerCase();
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Runs before the size cap: the PNG is larger than the WebP it came from. A WebP already over
|
|
99
|
+
// the cap is passed through unconverted, so the cap rejects it without a full decode.
|
|
100
|
+
async function prepare(
|
|
101
|
+
blob: Blob,
|
|
102
|
+
fileEnding: string
|
|
103
|
+
): Promise<{ blob: Blob; fileEnding: string }> {
|
|
104
|
+
if (blob.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) return { blob, fileEnding };
|
|
105
|
+
if (!shouldConvertWebp(fileEnding, acceptedFormats)) return { blob, fileEnding };
|
|
106
|
+
return { blob: await webpToPng(blob), fileEnding: '.png' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readBase64(blob: Blob): Promise<string> {
|
|
110
|
+
return new Promise<string>((resolve, reject) => {
|
|
111
|
+
const reader = new FileReader();
|
|
112
|
+
reader.onload = (e) => {
|
|
113
|
+
const result = e.target?.result as string;
|
|
114
|
+
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
115
|
+
};
|
|
116
|
+
reader.onerror = () => reject(new Error(t.fileReadFailed));
|
|
117
|
+
reader.readAsDataURL(blob);
|
|
118
|
+
});
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
function isCorsError(error: unknown): boolean {
|
|
@@ -138,14 +157,16 @@
|
|
|
138
157
|
return;
|
|
139
158
|
}
|
|
140
159
|
|
|
141
|
-
const
|
|
142
|
-
const
|
|
160
|
+
const fetched = await response.blob();
|
|
161
|
+
const requestedEnding = (await isWebp(fetched)) ? '.webp' : getFileExtension(urlInput);
|
|
143
162
|
|
|
144
|
-
if (!
|
|
145
|
-
urlError = { message: formatNotAccepted(
|
|
163
|
+
if (!acceptsFileEnding(requestedEnding, acceptedFormats)) {
|
|
164
|
+
urlError = { message: formatNotAccepted(requestedEnding), isCors: false };
|
|
146
165
|
return;
|
|
147
166
|
}
|
|
148
167
|
|
|
168
|
+
const { blob, fileEnding } = await prepare(fetched, requestedEnding);
|
|
169
|
+
|
|
149
170
|
const maxSize = APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES;
|
|
150
171
|
if (blob.size > maxSize) {
|
|
151
172
|
urlError = {
|
|
@@ -155,15 +176,7 @@
|
|
|
155
176
|
return;
|
|
156
177
|
}
|
|
157
178
|
|
|
158
|
-
const base64Data = await
|
|
159
|
-
const reader = new FileReader();
|
|
160
|
-
reader.onload = (e) => {
|
|
161
|
-
const result = e.target?.result as string;
|
|
162
|
-
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
163
|
-
};
|
|
164
|
-
reader.onerror = () => reject(new Error(t.fileReadFailed));
|
|
165
|
-
reader.readAsDataURL(blob);
|
|
166
|
-
});
|
|
179
|
+
const base64Data = await readBase64(blob);
|
|
167
180
|
const data = JSON.stringify({ file: base64Data, type: 'base64', fileEnding });
|
|
168
181
|
value = data;
|
|
169
182
|
urlSuccess = formatMessage(t.fileLoaded, {
|
|
@@ -206,48 +219,39 @@
|
|
|
206
219
|
}
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
function
|
|
210
|
-
const
|
|
211
|
-
const file = target.files?.[0];
|
|
212
|
-
if (!file) return;
|
|
213
|
-
|
|
214
|
-
const fileEnding = getFileExtension(file.name);
|
|
215
|
-
|
|
216
|
-
if (!isValidFileExtension(fileEnding)) {
|
|
217
|
-
toast.error(formatNotAccepted(fileEnding));
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
222
|
+
async function handleFile(file: File) {
|
|
223
|
+
const requestedEnding = getFileExtension(file.name);
|
|
220
224
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// currently LOOSER than the server's COMPUTE_REQUEST_MAX_BYTES (see the TEMP
|
|
224
|
-
// note in constants.ts) — some files pass here and still 413. The URL import
|
|
225
|
-
// path applies the same cap.
|
|
226
|
-
if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
227
|
-
toast.error(formatTooLarge(file.size));
|
|
225
|
+
if (!acceptsFileEnding(requestedEnding, acceptedFormats)) {
|
|
226
|
+
toast.error(formatNotAccepted(requestedEnding));
|
|
228
227
|
return;
|
|
229
228
|
}
|
|
230
229
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
230
|
+
try {
|
|
231
|
+
const { blob, fileEnding } = await prepare(file, requestedEnding);
|
|
232
|
+
|
|
233
|
+
// The file is base64-embedded into the compute request body, so without a
|
|
234
|
+
// client-side cap an oversize file comes back as an opaque 413. This cap is
|
|
235
|
+
// currently LOOSER than the server's COMPUTE_REQUEST_MAX_BYTES (see the TEMP
|
|
236
|
+
// note in constants.ts) — some files pass here and still 413. The URL import
|
|
237
|
+
// path applies the same cap.
|
|
238
|
+
if (blob.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
239
|
+
toast.error(formatTooLarge(blob.size));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
237
242
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
type: 'base64',
|
|
241
|
-
fileEnding
|
|
242
|
-
});
|
|
243
|
+
uploadedFileName = file.name;
|
|
244
|
+
const data = JSON.stringify({ file: await readBase64(blob), type: 'base64', fileEnding });
|
|
243
245
|
value = data;
|
|
244
246
|
onChange(data);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
+
} catch (error) {
|
|
248
|
+
toast.error(error instanceof Error ? error.message : t.fileReadFailed);
|
|
249
|
+
}
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
function openFilePicker() {
|
|
250
|
-
|
|
252
|
+
async function openFilePicker() {
|
|
253
|
+
const file = await pickFile({ accept: acceptAttribute });
|
|
254
|
+
if (file) handleFile(file);
|
|
251
255
|
}
|
|
252
256
|
|
|
253
257
|
function handleDragOver(e: DragEvent) {
|
|
@@ -263,27 +267,14 @@
|
|
|
263
267
|
e.preventDefault();
|
|
264
268
|
isDragging = false;
|
|
265
269
|
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
268
|
-
const fakeEvent = new Event('change');
|
|
269
|
-
const fakeTarget = { files: files } as HTMLInputElement;
|
|
270
|
-
Object.defineProperty(fakeEvent, 'target', { value: fakeTarget });
|
|
271
|
-
handleFileUpload(fakeEvent);
|
|
272
|
-
}
|
|
270
|
+
const file = e.dataTransfer?.files?.[0];
|
|
271
|
+
if (file) handleFile(file);
|
|
273
272
|
}
|
|
274
273
|
|
|
275
274
|
const acceptAttribute = $derived(acceptedFormats.length > 0 ? acceptedFormats.join(',') : '*');
|
|
276
275
|
</script>
|
|
277
276
|
|
|
278
277
|
<div class="gap-2.5 flex w-full flex-col">
|
|
279
|
-
<input
|
|
280
|
-
bind:this={fileInput}
|
|
281
|
-
type="file"
|
|
282
|
-
accept={acceptAttribute}
|
|
283
|
-
onchange={handleFileUpload}
|
|
284
|
-
class="hidden"
|
|
285
|
-
/>
|
|
286
|
-
|
|
287
278
|
{#if showToggle}
|
|
288
279
|
<div class="p-0.5 text-xs flex rounded-md bg-muted">
|
|
289
280
|
{#each effectiveModes as mode (mode)}
|