@selvajs/ui 6.1.2 → 6.3.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/dist/components/ConfirmDialog.svelte +59 -0
- package/dist/components/ConfirmDialog.svelte.d.ts +13 -0
- package/dist/components/compute/ComputeApp.svelte +10 -0
- package/dist/components/compute/ComputeApp.svelte.d.ts +3 -0
- package/dist/components/compute/ParameterPresetManager.svelte +3 -3
- package/dist/components/layout/AppShell.svelte +19 -2
- package/dist/components/layout/SideNav.svelte +16 -2
- package/dist/components/layout/SideNav.svelte.d.ts +5 -0
- package/dist/components/preview/inputs/FileInput.svelte +3 -2
- package/dist/components/primitives/Callout.svelte +73 -0
- package/dist/components/primitives/Callout.svelte.d.ts +40 -0
- package/dist/components/primitives/index.d.ts +2 -0
- package/dist/components/primitives/index.js +2 -0
- package/dist/components/viewer/MeshMetadataDialog.svelte +1 -1
- package/dist/components/viewer/SceneManager.svelte +274 -103
- package/dist/components/viewer/SceneManager.svelte.d.ts +6 -0
- package/dist/components/viewer/Viewer.svelte +104 -32
- package/dist/i18n/messages.d.ts +4 -2
- package/dist/i18n/messages.js +8 -4
- package/package.json +14 -14
- package/src/lib/components/ConfirmDialog.svelte +59 -0
- package/src/lib/components/compute/ComputeApp.svelte +10 -0
- package/src/lib/components/compute/ParameterPresetManager.svelte +3 -3
- package/src/lib/components/layout/AppShell.svelte +19 -2
- package/src/lib/components/layout/SideNav.svelte +16 -2
- package/src/lib/components/preview/inputs/FileInput.svelte +3 -2
- package/src/lib/components/primitives/Callout.svelte +73 -0
- package/src/lib/components/primitives/index.ts +2 -0
- package/src/lib/components/viewer/MeshMetadataDialog.svelte +1 -1
- package/src/lib/components/viewer/SceneManager.svelte +274 -103
- package/src/lib/components/viewer/Viewer.svelte +104 -32
- package/src/lib/i18n/messages.ts +12 -6
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
initThree,
|
|
6
6
|
updateScene,
|
|
7
7
|
LOOKS,
|
|
8
|
+
LOOK_PRESETS,
|
|
8
9
|
type Look,
|
|
9
10
|
type ThreeInitializerOptions,
|
|
10
11
|
type CameraController,
|
|
@@ -118,11 +119,13 @@
|
|
|
118
119
|
let cameraController: CameraController | null = null;
|
|
119
120
|
let measureTool: MeasureTool | null = null;
|
|
120
121
|
let grid: Grid | null = null;
|
|
121
|
-
let applyEdges:
|
|
122
|
-
let clearEdges:
|
|
122
|
+
let applyEdges: ThreeViewer['applyEdges'] | null = null;
|
|
123
|
+
let clearEdges: ThreeViewer['clearEdges'] | null = null;
|
|
123
124
|
let invalidate: (() => void) | null = null;
|
|
125
|
+
let captureImage: ThreeViewer['captureImage'] | null = null;
|
|
124
126
|
let setLook: ((look: Look) => void) | null = null;
|
|
125
127
|
let updateGridScale: (() => void) | null = null;
|
|
128
|
+
let updateShadowBounds: (() => void) | null = null;
|
|
126
129
|
let fitToView: (() => void) | null = null;
|
|
127
130
|
let viewerInitialized = false;
|
|
128
131
|
let sceneVersion = $state(0);
|
|
@@ -144,10 +147,15 @@
|
|
|
144
147
|
let selectedMeshMetadata: Record<string, any> | null = $state(null);
|
|
145
148
|
let selectedMeshName: string | null = $state(null);
|
|
146
149
|
|
|
147
|
-
// Derived from LOOKS so adding a look in @selvajs/visualization shows up here with no edit
|
|
150
|
+
// Derived from LOOKS so adding a look in @selvajs/visualization shows up here with no edit; the
|
|
151
|
+
// map only overrides the names that don't survive capitalisation ('xray' → 'X-Ray').
|
|
152
|
+
const LOOK_LABELS: Partial<Record<Look, string>> = {
|
|
153
|
+
xray: 'X-Ray',
|
|
154
|
+
lineart: 'Line Art'
|
|
155
|
+
};
|
|
148
156
|
const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
|
|
149
157
|
look,
|
|
150
|
-
label: look.charAt(0).toUpperCase() + look.slice(1)
|
|
158
|
+
label: LOOK_LABELS[look] ?? look.charAt(0).toUpperCase() + look.slice(1)
|
|
151
159
|
}));
|
|
152
160
|
|
|
153
161
|
const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
|
|
@@ -175,12 +183,11 @@
|
|
|
175
183
|
onMount(() => {
|
|
176
184
|
if (!canvas) return;
|
|
177
185
|
|
|
178
|
-
// Only what differs from the library defaults.
|
|
179
|
-
//
|
|
186
|
+
// Only what differs from the library defaults. Sunlight, shadows and AO are left ON (the
|
|
187
|
+
// library default) and their strength comes from the look — with IBL alone every face of a
|
|
188
|
+
// box lights nearly equally and the model reads as a flat white silhouette.
|
|
180
189
|
const opts: ThreeInitializerOptions = {
|
|
181
190
|
look: renderStyle,
|
|
182
|
-
lighting: { enableSunlight: false },
|
|
183
|
-
render: { enableShadows: false },
|
|
184
191
|
environment: { backgroundColor: config.backgroundColor },
|
|
185
192
|
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
186
193
|
measure: { enabled: config.showToolsMenu },
|
|
@@ -210,8 +217,10 @@
|
|
|
210
217
|
applyEdges = init.applyEdges;
|
|
211
218
|
clearEdges = init.clearEdges;
|
|
212
219
|
invalidate = init.invalidate;
|
|
220
|
+
captureImage = init.captureImage;
|
|
213
221
|
setLook = init.setLook;
|
|
214
222
|
updateGridScale = init.updateGridScale;
|
|
223
|
+
updateShadowBounds = init.updateShadowBounds;
|
|
215
224
|
fitToView = init.fitToView;
|
|
216
225
|
projection = init.cameraController.getProjection();
|
|
217
226
|
|
|
@@ -253,22 +262,84 @@
|
|
|
253
262
|
invalidate?.();
|
|
254
263
|
}
|
|
255
264
|
|
|
265
|
+
// Edges the lineart look switched on, so leaving it can put the user's own choice back rather
|
|
266
|
+
// than stranding them with overlays they never asked for.
|
|
267
|
+
let edgesBeforeLineDrawing: boolean | null = null;
|
|
268
|
+
|
|
256
269
|
function setRenderStyle(look: Look) {
|
|
257
270
|
if (!setLook || look === renderStyle) return;
|
|
271
|
+
const wasLineDrawing = LOOK_PRESETS[renderStyle].requiresEdges === true;
|
|
258
272
|
renderStyle = look;
|
|
259
273
|
setLook(look);
|
|
274
|
+
|
|
275
|
+
// A look never drives the overlay itself; honouring `requiresEdges` is the host's call. Without
|
|
276
|
+
// edges lineart is blank white shapes, so entering it forces them on.
|
|
277
|
+
if (LOOK_PRESETS[look].requiresEdges) {
|
|
278
|
+
if (!wasLineDrawing) edgesBeforeLineDrawing = edgesVisible;
|
|
279
|
+
if (!edgesVisible) {
|
|
280
|
+
edgesVisible = true;
|
|
281
|
+
} else if (!wasLineDrawing && scene) {
|
|
282
|
+
// Already-attached overlays carry the old fade setting, and addEdges skips a mesh that
|
|
283
|
+
// has one — so they have to go before the un-faded pass can replace them.
|
|
284
|
+
clearEdges?.(scene);
|
|
285
|
+
}
|
|
286
|
+
applyEdgeState();
|
|
287
|
+
} else if (wasLineDrawing && edgesBeforeLineDrawing !== null) {
|
|
288
|
+
edgesVisible = edgesBeforeLineDrawing;
|
|
289
|
+
// Rebuild either way: staying on means swapping the un-faded overlays back for faded ones.
|
|
290
|
+
if (edgesVisible && scene) clearEdges?.(scene);
|
|
291
|
+
applyEdgeState();
|
|
292
|
+
edgesBeforeLineDrawing = null;
|
|
293
|
+
} else if (edgesVisible && scene) {
|
|
294
|
+
// Neither look is a line drawing, but an overlay's colour is derived from its mesh's
|
|
295
|
+
// material — which `setLook` just repainted. addEdges skips meshes that already have an
|
|
296
|
+
// overlay, so without a rebuild the edges keep the previous look's colour.
|
|
297
|
+
clearEdges?.(scene);
|
|
298
|
+
applyEdgeState();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Meshes past the overlay budget get the screen-space fallback instead, and that pass thresholds
|
|
303
|
+
// a depth/normal discontinuity to decide what is an edge — so a shallow crease sitting near the
|
|
304
|
+
// cutoff flips on and off as the camera turns a degree. Tolerable as a hint over a shaded model;
|
|
305
|
+
// in a line drawing it is the picture itself flickering. Raised far enough that a building model
|
|
306
|
+
// gets real overlays throughout: they cost draw calls, but they are stable under orbit.
|
|
307
|
+
const LINE_ART_MAX_OVERLAYS = 20_000;
|
|
308
|
+
|
|
309
|
+
// Edge colour is normally derived per mesh from that mesh's own material, darkened. lineart
|
|
310
|
+
// repaints every material near-white, so a derived edge lands at rgb(62,62,62) or lighter — and
|
|
311
|
+
// worse, `setLook` and `applyEdges` are independent, so whether the derivation sees the white
|
|
312
|
+
// override or the model's original colours depends on which ran last. That is the real source of
|
|
313
|
+
// the "edges look different every time" behaviour. Forcing a colour makes it deterministic.
|
|
314
|
+
const LINE_ART_EDGE_COLOR = 0x1a1d21;
|
|
315
|
+
|
|
316
|
+
// The density fade sets opacity per overlay, so a finely-detailed mesh fades as a whole and its
|
|
317
|
+
// long silhouette edges go with it — in a line drawing that erases the only thing on screen, and
|
|
318
|
+
// it recomputes per frame, so edges pop while orbiting. Shaded looks keep it: there it just
|
|
319
|
+
// softens outlines on a model you can still see.
|
|
320
|
+
function edgeOverrides() {
|
|
321
|
+
return LOOK_PRESETS[renderStyle].requiresEdges
|
|
322
|
+
? {
|
|
323
|
+
distanceFade: false,
|
|
324
|
+
maxOverlays: LINE_ART_MAX_OVERLAYS,
|
|
325
|
+
color: LINE_ART_EDGE_COLOR
|
|
326
|
+
}
|
|
327
|
+
: undefined;
|
|
260
328
|
}
|
|
261
329
|
|
|
262
330
|
// `applyEdges` is idempotent per mesh, so the repeated calls after each solve add no duplicate
|
|
263
331
|
// overlays; `clearEdges` is its inverse.
|
|
264
332
|
function applyEdgeState() {
|
|
265
333
|
if (!scene) return;
|
|
266
|
-
if (edgesVisible) applyEdges?.(scene);
|
|
334
|
+
if (edgesVisible) applyEdges?.(scene, edgeOverrides());
|
|
267
335
|
else clearEdges?.(scene);
|
|
268
336
|
}
|
|
269
337
|
|
|
270
338
|
function toggleEdges() {
|
|
271
339
|
edgesVisible = !edgesVisible;
|
|
340
|
+
// Toggling by hand inside lineart overrides what that look forced, so the pre-look value is
|
|
341
|
+
// stale — leaving the look must not undo the user's more recent explicit choice.
|
|
342
|
+
if (LOOK_PRESETS[renderStyle].requiresEdges) edgesBeforeLineDrawing = null;
|
|
272
343
|
applyEdgeState();
|
|
273
344
|
}
|
|
274
345
|
|
|
@@ -278,10 +349,16 @@
|
|
|
278
349
|
// Untracked because toggleEdges() already handles the toggle directly — reading
|
|
279
350
|
// `edgesVisible` tracked here would re-trigger a full solve.
|
|
280
351
|
untrack(() => {
|
|
352
|
+
// The new meshes carry the materials the parser built, so a look that overrides them
|
|
353
|
+
// (arctic, x-ray) has to be re-applied or the solve silently reverts to shaded.
|
|
354
|
+
setLook?.(renderStyle);
|
|
281
355
|
// updateScene discarded the previous solve's overlays along with its content.
|
|
282
|
-
if (edgesVisible) applyEdges?.(scene
|
|
356
|
+
if (edgesVisible) applyEdges?.(scene!, edgeOverrides());
|
|
283
357
|
// Rescale the grid so cells and fade match the new content's extent.
|
|
284
358
|
updateGridScale?.();
|
|
359
|
+
// The shadow frustum is sized to scene content, so it has to follow the new geometry —
|
|
360
|
+
// left at the old extent, shadows go blocky or fall outside the map entirely.
|
|
361
|
+
updateShadowBounds?.();
|
|
285
362
|
// The rebuild un-hid everything; the outliner keys hidden state on Grasshopper
|
|
286
363
|
// identity, not on the instances just discarded, so it can re-hide it.
|
|
287
364
|
outliner?.applyTo();
|
|
@@ -311,8 +388,7 @@
|
|
|
311
388
|
const EXCLUDED_KEYS = new Set([
|
|
312
389
|
'name',
|
|
313
390
|
'layer',
|
|
314
|
-
'
|
|
315
|
-
'sourceComponentId',
|
|
391
|
+
'id',
|
|
316
392
|
'vertexCount',
|
|
317
393
|
'faceCount',
|
|
318
394
|
'vertexOffset',
|
|
@@ -322,23 +398,19 @@
|
|
|
322
398
|
return usefulEntries.length > 0;
|
|
323
399
|
}
|
|
324
400
|
|
|
325
|
-
function downloadScreenshot() {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
document.body.removeChild(link);
|
|
339
|
-
URL.revokeObjectURL(url);
|
|
340
|
-
});
|
|
341
|
-
});
|
|
401
|
+
async function downloadScreenshot() {
|
|
402
|
+
const blob = await captureImage?.();
|
|
403
|
+
if (!blob) return;
|
|
404
|
+
|
|
405
|
+
const url = URL.createObjectURL(blob);
|
|
406
|
+
const link = document.createElement('a');
|
|
407
|
+
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
|
408
|
+
link.href = url;
|
|
409
|
+
link.download = `viewer-${timestamp}.png`;
|
|
410
|
+
document.body.appendChild(link);
|
|
411
|
+
link.click();
|
|
412
|
+
document.body.removeChild(link);
|
|
413
|
+
URL.revokeObjectURL(url);
|
|
342
414
|
}
|
|
343
415
|
</script>
|
|
344
416
|
|
|
@@ -422,10 +494,10 @@
|
|
|
422
494
|
<DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
|
|
423
495
|
{#if projection === 'perspective'}
|
|
424
496
|
<Square class="h-4 w-4" />
|
|
425
|
-
{t.
|
|
497
|
+
{t.switchToOrthographic}
|
|
426
498
|
{:else}
|
|
427
499
|
<Box class="h-4 w-4" />
|
|
428
|
-
{t.
|
|
500
|
+
{t.switchToPerspective}
|
|
429
501
|
{/if}
|
|
430
502
|
</DropdownMenu.Item>
|
|
431
503
|
|
|
@@ -569,7 +641,7 @@
|
|
|
569
641
|
{#if sceneManagerOpen && scene && outliner}
|
|
570
642
|
<Resizable.Handle withHandle />
|
|
571
643
|
<Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
|
|
572
|
-
<SceneManager {outliner} {sceneVersion} />
|
|
644
|
+
<SceneManager {outliner} {sceneVersion} onVisibilityChange={() => invalidate?.()} />
|
|
573
645
|
</Resizable.Pane>
|
|
574
646
|
{/if}
|
|
575
647
|
</Resizable.PaneGroup>
|
package/dist/i18n/messages.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export type Locale = 'en' | 'de';
|
|
2
2
|
export interface ViewerMessages {
|
|
3
3
|
toolsMenu: string;
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
switchToOrthographic: string;
|
|
5
|
+
switchToPerspective: string;
|
|
6
6
|
fitToView: string;
|
|
7
7
|
views: string;
|
|
8
8
|
measure: string;
|
|
@@ -24,6 +24,8 @@ export interface ViewerMessages {
|
|
|
24
24
|
clearSearch: string;
|
|
25
25
|
expandLayer: string;
|
|
26
26
|
collapseLayer: string;
|
|
27
|
+
expandAll: string;
|
|
28
|
+
collapseAll: string;
|
|
27
29
|
showLayer: string;
|
|
28
30
|
hideLayer: string;
|
|
29
31
|
showObject: string;
|
package/dist/i18n/messages.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// cannot be translated here.
|
|
8
8
|
const en = {
|
|
9
9
|
toolsMenu: 'Viewer tools',
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
switchToOrthographic: 'Orthographic camera',
|
|
11
|
+
switchToPerspective: 'Perspective camera',
|
|
12
12
|
fitToView: 'Fit to view',
|
|
13
13
|
views: 'Views',
|
|
14
14
|
measure: 'Measure',
|
|
@@ -30,6 +30,8 @@ const en = {
|
|
|
30
30
|
clearSearch: 'Clear search',
|
|
31
31
|
expandLayer: 'Expand layer',
|
|
32
32
|
collapseLayer: 'Collapse layer',
|
|
33
|
+
expandAll: 'Expand all',
|
|
34
|
+
collapseAll: 'Collapse all',
|
|
33
35
|
showLayer: 'Show layer',
|
|
34
36
|
hideLayer: 'Hide layer',
|
|
35
37
|
showObject: 'Show object',
|
|
@@ -56,8 +58,8 @@ const en = {
|
|
|
56
58
|
};
|
|
57
59
|
const de = {
|
|
58
60
|
toolsMenu: 'Viewer-Werkzeuge',
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
switchToOrthographic: 'Orthografische Kamera',
|
|
62
|
+
switchToPerspective: 'Perspektivische Kamera',
|
|
61
63
|
fitToView: 'Ansicht anpassen',
|
|
62
64
|
views: 'Ansichten',
|
|
63
65
|
measure: 'Messen',
|
|
@@ -79,6 +81,8 @@ const de = {
|
|
|
79
81
|
clearSearch: 'Suche löschen',
|
|
80
82
|
expandLayer: 'Ebene aufklappen',
|
|
81
83
|
collapseLayer: 'Ebene zuklappen',
|
|
84
|
+
expandAll: 'Alle aufklappen',
|
|
85
|
+
collapseAll: 'Alle zuklappen',
|
|
82
86
|
showLayer: 'Ebene einblenden',
|
|
83
87
|
hideLayer: 'Ebene ausblenden',
|
|
84
88
|
showObject: 'Objekt einblenden',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "VektorNode",
|
|
@@ -59,10 +59,10 @@
|
|
|
59
59
|
"svelte": "^5",
|
|
60
60
|
"tailwind-variants": "^3.3.1",
|
|
61
61
|
"three": "^0.185.1",
|
|
62
|
-
"@selvajs/compute": "^4.
|
|
63
|
-
"@selvajs/
|
|
64
|
-
"@selvajs/
|
|
65
|
-
"@selvajs/
|
|
62
|
+
"@selvajs/compute": "^4.1.1",
|
|
63
|
+
"@selvajs/visualization": "^1.2.0",
|
|
64
|
+
"@selvajs/schemas": "^5.0.2",
|
|
65
|
+
"@selvajs/solve": "^1.0.8"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
68
|
"three": {
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"dependencies": {
|
|
73
73
|
"@floating-ui/dom": "^1.8.0",
|
|
74
74
|
"@iconify/svelte": "^5.2.1",
|
|
75
|
-
"@lucide/svelte": "^1.
|
|
75
|
+
"@lucide/svelte": "^1.35.0",
|
|
76
76
|
"clsx": "^2.1.1",
|
|
77
77
|
"mode-watcher": "^1.1.0",
|
|
78
78
|
"paneforge": "^1.0.2",
|
|
@@ -82,19 +82,19 @@
|
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@internationalized/date": "^3.12.3",
|
|
85
|
-
"@sveltejs/kit": "2.70.
|
|
85
|
+
"@sveltejs/kit": "2.70.3",
|
|
86
86
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
87
87
|
"@types/three": "^0.185.4",
|
|
88
|
-
"bits-ui": "^2.
|
|
88
|
+
"bits-ui": "^2.19.0",
|
|
89
89
|
"rimraf": "^6.1.3",
|
|
90
|
-
"svelte": "5.56.
|
|
90
|
+
"svelte": "5.56.10",
|
|
91
91
|
"tailwind-variants": "^3.3.1",
|
|
92
|
-
"vitest": "^4.1.
|
|
92
|
+
"vitest": "^4.1.11",
|
|
93
|
+
"@selvajs/compute": "4.1.1",
|
|
93
94
|
"@selvajs/config": "0.0.4",
|
|
94
|
-
"@selvajs/
|
|
95
|
-
"@selvajs/
|
|
96
|
-
"@selvajs/solve": "1.0.
|
|
97
|
-
"@selvajs/compute": "4.0.2"
|
|
95
|
+
"@selvajs/visualization": "1.2.0",
|
|
96
|
+
"@selvajs/schemas": "5.0.2",
|
|
97
|
+
"@selvajs/solve": "1.0.8"
|
|
98
98
|
},
|
|
99
99
|
"scripts": {
|
|
100
100
|
"predev": "node ../../scripts/sync-shared-assets.js",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as AlertDialog from './primitives/alert-dialog/index.js';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
open = $bindable(false),
|
|
6
|
+
title,
|
|
7
|
+
description,
|
|
8
|
+
confirmLabel = 'Continue',
|
|
9
|
+
pendingLabel,
|
|
10
|
+
cancelLabel = 'Cancel',
|
|
11
|
+
variant = 'default',
|
|
12
|
+
onConfirm
|
|
13
|
+
}: {
|
|
14
|
+
open?: boolean;
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
confirmLabel?: string;
|
|
18
|
+
pendingLabel?: string;
|
|
19
|
+
cancelLabel?: string;
|
|
20
|
+
variant?: 'default' | 'destructive';
|
|
21
|
+
onConfirm: () => void | Promise<void>;
|
|
22
|
+
} = $props();
|
|
23
|
+
|
|
24
|
+
let pending = $state(false);
|
|
25
|
+
|
|
26
|
+
async function handleConfirm() {
|
|
27
|
+
pending = true;
|
|
28
|
+
try {
|
|
29
|
+
await onConfirm();
|
|
30
|
+
} finally {
|
|
31
|
+
pending = false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<AlertDialog.Root bind:open onOpenChange={(next) => !pending && (open = next)}>
|
|
37
|
+
<AlertDialog.Content>
|
|
38
|
+
<AlertDialog.Header>
|
|
39
|
+
<AlertDialog.Title>{title}</AlertDialog.Title>
|
|
40
|
+
{#if description}
|
|
41
|
+
<AlertDialog.Description class={variant === 'destructive' ? 'text-destructive' : undefined}>
|
|
42
|
+
{description}
|
|
43
|
+
</AlertDialog.Description>
|
|
44
|
+
{/if}
|
|
45
|
+
</AlertDialog.Header>
|
|
46
|
+
<AlertDialog.Footer>
|
|
47
|
+
<AlertDialog.Cancel disabled={pending}>{cancelLabel}</AlertDialog.Cancel>
|
|
48
|
+
<AlertDialog.Action
|
|
49
|
+
onclick={handleConfirm}
|
|
50
|
+
disabled={pending}
|
|
51
|
+
class={variant === 'destructive'
|
|
52
|
+
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
|
|
53
|
+
: undefined}
|
|
54
|
+
>
|
|
55
|
+
{pending ? (pendingLabel ?? confirmLabel) : confirmLabel}
|
|
56
|
+
</AlertDialog.Action>
|
|
57
|
+
</AlertDialog.Footer>
|
|
58
|
+
</AlertDialog.Content>
|
|
59
|
+
</AlertDialog.Root>
|
|
@@ -72,6 +72,10 @@
|
|
|
72
72
|
/** Viewer chrome and defaults. `backgroundColor` and `showSceneManager` are set by the layout. */
|
|
73
73
|
viewerConfig?: ViewerConfig;
|
|
74
74
|
headerRight?: Snippet;
|
|
75
|
+
// Primary nav rendered next to the brand, so the viewer keeps the app-wide nav.
|
|
76
|
+
navItems?: Snippet;
|
|
77
|
+
homeUrl?: string;
|
|
78
|
+
brandName?: string;
|
|
75
79
|
// Replaces the built-in header; takes precedence over `headerRight`.
|
|
76
80
|
header?: Snippet;
|
|
77
81
|
// Scopes sessionStorage for external-input values; falls back to definitionKey then schema.id.
|
|
@@ -106,6 +110,9 @@
|
|
|
106
110
|
footerItemId = 'footer-item',
|
|
107
111
|
footerItemPriority = 0,
|
|
108
112
|
headerRight,
|
|
113
|
+
navItems,
|
|
114
|
+
homeUrl,
|
|
115
|
+
brandName,
|
|
109
116
|
header,
|
|
110
117
|
onReady,
|
|
111
118
|
onViewerReady,
|
|
@@ -215,6 +222,9 @@
|
|
|
215
222
|
showFooter
|
|
216
223
|
title={pageTitle}
|
|
217
224
|
{showModeToggle}
|
|
225
|
+
{navItems}
|
|
226
|
+
{homeUrl}
|
|
227
|
+
{brandName}
|
|
218
228
|
{copyrightName}
|
|
219
229
|
{footerText}
|
|
220
230
|
{header}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
exportStateAsJson,
|
|
9
9
|
importStateFromJson
|
|
10
10
|
} from '../../schema/param-exporter';
|
|
11
|
-
import { Button, Input, Label, Textarea, Dialog, Card } from '../primitives';
|
|
11
|
+
import { Button, Input, Label, Textarea, Dialog, Card, toast } from '../primitives';
|
|
12
12
|
|
|
13
13
|
import type { ActionButton } from '../../types/actionButton';
|
|
14
14
|
import { DEFAULT_PRESET_LABELS, type PresetLabels } from '../../types/presetLabels';
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
|
|
69
69
|
async function handleExport() {
|
|
70
70
|
if (!exportName.trim()) {
|
|
71
|
-
|
|
71
|
+
toast.error(t.saveNameRequired);
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
const imported = await importStateFromJson(input.files[0]);
|
|
109
109
|
tryLoad(imported);
|
|
110
110
|
} catch (error) {
|
|
111
|
-
|
|
111
|
+
toast.error(t.loadImportError + (error as Error).message);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
// Reset so re-picking the same file fires `change` again.
|
|
@@ -81,6 +81,21 @@
|
|
|
81
81
|
);
|
|
82
82
|
|
|
83
83
|
const bodyShellStyle = '';
|
|
84
|
+
|
|
85
|
+
// In scroll mode the page itself scrolls, so the sidenav has to be pinned below the
|
|
86
|
+
// header or it scrolls away with the content. Fixed mode already gives the row its own
|
|
87
|
+
// height and the aside its own scroll — sticky there would only shrink it to content.
|
|
88
|
+
const sidenavWrapClass = $derived(
|
|
89
|
+
_mode === 'fixed'
|
|
90
|
+
? 'flex shrink-0'
|
|
91
|
+
: 'flex shrink-0 self-start sticky top-(--header-h) max-h-[calc(100svh-var(--header-h))]'
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// The sticky wrapper is only as tall as its content, so in scroll mode the divider comes
|
|
95
|
+
// from the main column's left edge — that one spans the full body height.
|
|
96
|
+
const mainClass = $derived(
|
|
97
|
+
_mode === 'fixed' ? 'flex-1 overflow-y-auto' : 'flex-1 overflow-y-auto border-l border-border'
|
|
98
|
+
);
|
|
84
99
|
</script>
|
|
85
100
|
|
|
86
101
|
<div class={rootClass}>
|
|
@@ -110,8 +125,10 @@
|
|
|
110
125
|
|
|
111
126
|
<div class={bodyShellClass} style={bodyShellStyle}>
|
|
112
127
|
{#if sidenav}
|
|
113
|
-
{
|
|
114
|
-
|
|
128
|
+
<div class={sidenavWrapClass}>
|
|
129
|
+
{@render sidenav()}
|
|
130
|
+
</div>
|
|
131
|
+
<main class={mainClass}>
|
|
115
132
|
{@render children()}
|
|
116
133
|
</main>
|
|
117
134
|
{:else}
|
|
@@ -19,10 +19,22 @@
|
|
|
19
19
|
eyebrow?: string;
|
|
20
20
|
header?: Snippet;
|
|
21
21
|
footer?: Snippet;
|
|
22
|
+
/**
|
|
23
|
+
* Off when the divider is drawn by the surrounding layout instead — a sticky sidenav
|
|
24
|
+
* only reaches as far as its own content, so its border would stop mid-page.
|
|
25
|
+
*/
|
|
26
|
+
border?: boolean;
|
|
22
27
|
class?: string;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
let {
|
|
30
|
+
let {
|
|
31
|
+
items,
|
|
32
|
+
eyebrow,
|
|
33
|
+
header,
|
|
34
|
+
footer,
|
|
35
|
+
border = true,
|
|
36
|
+
class: className = ''
|
|
37
|
+
}: SideNavProps = $props();
|
|
26
38
|
|
|
27
39
|
function isActive(item: SideNavItem): boolean {
|
|
28
40
|
const path = page.url.pathname;
|
|
@@ -32,7 +44,9 @@
|
|
|
32
44
|
</script>
|
|
33
45
|
|
|
34
46
|
<aside
|
|
35
|
-
class={`w-60 flex shrink-0 flex-col overflow-y-auto
|
|
47
|
+
class={`w-60 flex h-full shrink-0 flex-col overflow-y-auto bg-background ${
|
|
48
|
+
border ? 'border-r border-border' : ''
|
|
49
|
+
} ${className}`}
|
|
36
50
|
>
|
|
37
51
|
{#if eyebrow}
|
|
38
52
|
<div class="px-4 pt-5 pb-3">
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Input } from '$lib/components/primitives/input';
|
|
3
3
|
import { Button } from '$lib/components/primitives/button';
|
|
4
4
|
import { Label } from '$lib/components/primitives/label';
|
|
5
|
+
import { toast } from '$lib/components/primitives/sonner';
|
|
5
6
|
import { FileUp, Link, CircleAlert, CircleCheck } from '@lucide/svelte';
|
|
6
7
|
import { APP_DEFAULTS } from '$lib/constants';
|
|
7
8
|
|
|
@@ -193,7 +194,7 @@
|
|
|
193
194
|
const fileEnding = getFileExtension(file.name);
|
|
194
195
|
|
|
195
196
|
if (!isValidFileExtension(fileEnding)) {
|
|
196
|
-
|
|
197
|
+
toast.error(`File format not accepted: ${fileEnding}`);
|
|
197
198
|
return;
|
|
198
199
|
}
|
|
199
200
|
|
|
@@ -203,7 +204,7 @@
|
|
|
203
204
|
// note in constants.ts) — some files pass here and still 413. The URL import
|
|
204
205
|
// path applies the same cap.
|
|
205
206
|
if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
206
|
-
|
|
207
|
+
toast.error(
|
|
207
208
|
`File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
|
|
208
209
|
);
|
|
209
210
|
return;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import { type VariantProps, tv } from 'tailwind-variants';
|
|
3
|
+
import { Info, TriangleAlert, CircleCheck, CircleX, Lightbulb } from '@lucide/svelte';
|
|
4
|
+
import type { Component } from 'svelte';
|
|
5
|
+
|
|
6
|
+
export const calloutVariants = tv({
|
|
7
|
+
base: 'flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-sm',
|
|
8
|
+
variants: {
|
|
9
|
+
tone: {
|
|
10
|
+
info: 'border-info/40 bg-info/10',
|
|
11
|
+
tip: 'border-border bg-muted/40',
|
|
12
|
+
success: 'border-success/40 bg-success/10',
|
|
13
|
+
warning: 'border-warning/40 bg-warning/10',
|
|
14
|
+
danger: 'border-destructive/40 bg-destructive/10'
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
defaultVariants: { tone: 'info' }
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type CalloutTone = NonNullable<VariantProps<typeof calloutVariants>['tone']>;
|
|
21
|
+
|
|
22
|
+
const TONE_ICON: Record<CalloutTone, Component> = {
|
|
23
|
+
info: Info,
|
|
24
|
+
tip: Lightbulb,
|
|
25
|
+
success: CircleCheck,
|
|
26
|
+
warning: TriangleAlert,
|
|
27
|
+
danger: CircleX
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TONE_ACCENT: Record<CalloutTone, string> = {
|
|
31
|
+
info: 'text-info',
|
|
32
|
+
tip: 'text-muted-foreground',
|
|
33
|
+
success: 'text-success',
|
|
34
|
+
warning: 'text-warning',
|
|
35
|
+
danger: 'text-destructive'
|
|
36
|
+
};
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<script lang="ts">
|
|
40
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
41
|
+
import type { Snippet } from 'svelte';
|
|
42
|
+
import { cn } from '$lib/utils.js';
|
|
43
|
+
|
|
44
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
45
|
+
tone?: CalloutTone;
|
|
46
|
+
title?: string;
|
|
47
|
+
/** Replaces the tone's default icon. `null` drops the icon entirely. */
|
|
48
|
+
icon?: Component | null;
|
|
49
|
+
children: Snippet;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let { tone = 'info', title, icon, class: className, children, ...restProps }: Props = $props();
|
|
53
|
+
|
|
54
|
+
const Icon = $derived(icon === undefined ? TONE_ICON[tone] : icon);
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<div
|
|
58
|
+
class={cn(calloutVariants({ tone }), className)}
|
|
59
|
+
role={tone === 'warning' || tone === 'danger' ? 'alert' : undefined}
|
|
60
|
+
{...restProps}
|
|
61
|
+
>
|
|
62
|
+
{#if Icon}
|
|
63
|
+
<Icon class={cn('mt-0.5 h-4 w-4 shrink-0', TONE_ACCENT[tone])} />
|
|
64
|
+
{/if}
|
|
65
|
+
<div class="min-w-0 space-y-1">
|
|
66
|
+
{#if title}
|
|
67
|
+
<p class={cn('leading-tight font-medium', TONE_ACCENT[tone])}>{title}</p>
|
|
68
|
+
{/if}
|
|
69
|
+
<div class="leading-relaxed text-muted-foreground [&_a]:underline [&_strong]:text-foreground">
|
|
70
|
+
{@render children()}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
@@ -32,6 +32,7 @@ export { ThemeSwitcher } from './theme-switcher';
|
|
|
32
32
|
|
|
33
33
|
// Custom components (not replaced by shadcn)
|
|
34
34
|
export { default as StateDisplay } from './StateDisplay.svelte';
|
|
35
|
+
export { default as Callout, calloutVariants, type CalloutTone } from './Callout.svelte';
|
|
35
36
|
export { default as CalculateButton } from './CalculateButton.svelte';
|
|
36
37
|
export { ModeToggle } from './mode-toggle';
|
|
37
38
|
export { default as ViewToggle } from './ViewToggle.svelte';
|
|
@@ -41,3 +42,4 @@ export {
|
|
|
41
42
|
default as FilterableDropdown,
|
|
42
43
|
type FilterableDropdownItem
|
|
43
44
|
} from './FilterableDropdown.svelte';
|
|
45
|
+
export { default as ConfirmDialog } from '../ConfirmDialog.svelte';
|