@selvajs/ui 6.6.0-beta.5 → 6.6.0-beta.7
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/ErrorScreen.svelte +1 -1
- package/dist/components/compute/AppLayout.svelte +69 -29
- package/dist/components/compute/AppLayout.svelte.d.ts +5 -0
- package/dist/components/compute/ComputeApp.svelte +1 -0
- package/dist/components/layout/AppShell.svelte +10 -6
- package/dist/components/layout/PageHeader.svelte +11 -7
- package/dist/components/layout/SectionHeader.svelte +4 -2
- package/dist/components/layout/SideNav.svelte +19 -11
- package/dist/components/preview/ChartOutput.svelte +7 -3
- package/dist/components/preview/ImageOutput.svelte +1 -1
- package/dist/components/preview/OutputDisplay.svelte +1 -1
- package/dist/components/primitives/DataTable.svelte +38 -4
- package/dist/components/primitives/DataTable.svelte.d.ts +2 -0
- package/dist/components/primitives/alert-dialog/alert-dialog-content.svelte +1 -1
- package/dist/components/primitives/dialog/dialog-content.svelte +1 -1
- package/dist/components/primitives/drawer/Drawer.svelte +1 -1
- package/dist/components/viewer/MeshMetadataPanel.svelte +119 -0
- package/dist/components/viewer/MeshMetadataPanel.svelte.d.ts +9 -0
- package/dist/components/viewer/Viewer.svelte +59 -24
- package/dist/styles/base.css +21 -1
- package/dist/utils/loadScript.d.ts +2 -0
- package/dist/utils/loadScript.js +2 -0
- package/package.json +9 -9
- package/src/lib/components/ErrorScreen.svelte +1 -1
- package/src/lib/components/compute/AppLayout.svelte +69 -29
- package/src/lib/components/compute/ComputeApp.svelte +1 -0
- package/src/lib/components/layout/AppShell.svelte +10 -6
- package/src/lib/components/layout/PageHeader.svelte +11 -7
- package/src/lib/components/layout/SectionHeader.svelte +4 -2
- package/src/lib/components/layout/SideNav.svelte +19 -11
- package/src/lib/components/preview/ChartOutput.svelte +7 -3
- package/src/lib/components/preview/ImageOutput.svelte +1 -1
- package/src/lib/components/preview/OutputDisplay.svelte +1 -1
- package/src/lib/components/primitives/DataTable.svelte +38 -4
- package/src/lib/components/primitives/alert-dialog/alert-dialog-content.svelte +1 -1
- package/src/lib/components/primitives/dialog/dialog-content.svelte +1 -1
- package/src/lib/components/primitives/drawer/Drawer.svelte +1 -1
- package/src/lib/components/viewer/MeshMetadataPanel.svelte +119 -0
- package/src/lib/components/viewer/Viewer.svelte +59 -24
- package/src/lib/styles/base.css +21 -1
- package/src/lib/utils/loadScript.ts +3 -0
- package/dist/components/viewer/MeshMetadataDialog.svelte +0 -126
- package/dist/components/viewer/MeshMetadataDialog.svelte.d.ts +0 -10
- package/src/lib/components/viewer/MeshMetadataDialog.svelte +0 -126
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { X } from '@lucide/svelte';
|
|
3
|
+
import { Button } from '../primitives/button/index.js';
|
|
4
|
+
import { getLocaleContext } from '../../i18n/localeContext.svelte';
|
|
5
|
+
|
|
6
|
+
const locale = getLocaleContext();
|
|
7
|
+
const t = $derived(locale.messages);
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
metadata: Record<string, any>;
|
|
11
|
+
meshName: string | null;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EXCLUDED_KEYS = new Set(['name', 'layer', 'id']);
|
|
17
|
+
|
|
18
|
+
let { metadata, meshName, onClose, class: className = '' }: Props = $props();
|
|
19
|
+
|
|
20
|
+
const filteredMetadata = $derived.by(() => {
|
|
21
|
+
const result: Record<string, unknown> = {};
|
|
22
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
23
|
+
if (EXCLUDED_KEYS.has(key)) continue;
|
|
24
|
+
if (
|
|
25
|
+
key === 'metadata' &&
|
|
26
|
+
typeof value === 'object' &&
|
|
27
|
+
value !== null &&
|
|
28
|
+
!Array.isArray(value)
|
|
29
|
+
) {
|
|
30
|
+
Object.assign(result, value);
|
|
31
|
+
} else {
|
|
32
|
+
result[key] = value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<!-- Not a modal: a modal locks the page, and on touch the tap that should dismiss it can race the
|
|
40
|
+
dialog's own dismiss handler, leaving the viewer frozen. A card lets the canvas keep orbiting. -->
|
|
41
|
+
<section
|
|
42
|
+
aria-label={meshName ?? t.objectFallbackName}
|
|
43
|
+
class="min-h-0 shadow-lg backdrop-blur-sm flex flex-col overflow-hidden rounded-lg border bg-background/95 {className}"
|
|
44
|
+
>
|
|
45
|
+
<header class="gap-2 py-1 pr-1 pl-4 flex items-center border-b">
|
|
46
|
+
<h2 class="text-sm font-semibold min-w-0 flex-1 truncate">
|
|
47
|
+
{meshName ?? t.objectFallbackName}
|
|
48
|
+
</h2>
|
|
49
|
+
<Button
|
|
50
|
+
variant="ghost"
|
|
51
|
+
size="icon-sm"
|
|
52
|
+
class="pointer-coarse:size-10 shrink-0"
|
|
53
|
+
aria-label={t.close}
|
|
54
|
+
title={t.close}
|
|
55
|
+
onclick={onClose}
|
|
56
|
+
>
|
|
57
|
+
<X />
|
|
58
|
+
</Button>
|
|
59
|
+
</header>
|
|
60
|
+
|
|
61
|
+
<div class="min-h-0 overflow-y-auto overscroll-contain">
|
|
62
|
+
{#if Object.keys(filteredMetadata).length === 0}
|
|
63
|
+
<p class="px-4 py-3 text-xs text-muted-foreground">{t.noMetadata}</p>
|
|
64
|
+
{:else}
|
|
65
|
+
<table class="text-xs w-full border-collapse">
|
|
66
|
+
<tbody>
|
|
67
|
+
{#each Object.entries(filteredMetadata) as [key, value] (key)}
|
|
68
|
+
<tr class="border-b border-border/60 last:border-b-0">
|
|
69
|
+
<th
|
|
70
|
+
scope="row"
|
|
71
|
+
class="px-4 py-2 font-normal text-left align-top whitespace-nowrap text-muted-foreground"
|
|
72
|
+
>{key}</th
|
|
73
|
+
>
|
|
74
|
+
<td class="px-4 py-2 text-right align-top text-foreground">
|
|
75
|
+
{#if typeof value === 'object' && value !== null}
|
|
76
|
+
{#if Array.isArray(value)}
|
|
77
|
+
<div class="space-y-1 text-left">
|
|
78
|
+
{#each value as item, index (index)}
|
|
79
|
+
{#if typeof item === 'object'}
|
|
80
|
+
<div class="space-y-0.5">
|
|
81
|
+
{#each Object.entries(item) as [k, v] (k)}
|
|
82
|
+
<div class="gap-2 flex">
|
|
83
|
+
<span class="shrink-0 text-muted-foreground">{k}:</span>
|
|
84
|
+
<span>{v}</span>
|
|
85
|
+
</div>
|
|
86
|
+
{/each}
|
|
87
|
+
</div>
|
|
88
|
+
{:else}
|
|
89
|
+
<div>{item}</div>
|
|
90
|
+
{/if}
|
|
91
|
+
{/each}
|
|
92
|
+
</div>
|
|
93
|
+
{:else}
|
|
94
|
+
<div class="space-y-0.5 text-left">
|
|
95
|
+
{#each Object.entries(value) as [k, v] (k)}
|
|
96
|
+
<div class="gap-2 flex">
|
|
97
|
+
<span class="shrink-0 text-muted-foreground">{k}:</span>
|
|
98
|
+
<span>{v}</span>
|
|
99
|
+
</div>
|
|
100
|
+
{/each}
|
|
101
|
+
</div>
|
|
102
|
+
{/if}
|
|
103
|
+
{:else if typeof value === 'number'}
|
|
104
|
+
<span class="font-mono">{value}</span>
|
|
105
|
+
{:else if typeof value === 'boolean'}
|
|
106
|
+
<span class={value ? 'text-success' : 'text-muted-foreground'}
|
|
107
|
+
>{value ? 'true' : 'false'}</span
|
|
108
|
+
>
|
|
109
|
+
{:else}
|
|
110
|
+
<span class="break-all">{value}</span>
|
|
111
|
+
{/if}
|
|
112
|
+
</td>
|
|
113
|
+
</tr>
|
|
114
|
+
{/each}
|
|
115
|
+
</tbody>
|
|
116
|
+
</table>
|
|
117
|
+
{/if}
|
|
118
|
+
</div>
|
|
119
|
+
</section>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
metadata: Record<string, any>;
|
|
3
|
+
meshName: string | null;
|
|
4
|
+
onClose: () => void;
|
|
5
|
+
class?: string;
|
|
6
|
+
}
|
|
7
|
+
declare const MeshMetadataPanel: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type MeshMetadataPanel = ReturnType<typeof MeshMetadataPanel>;
|
|
9
|
+
export default MeshMetadataPanel;
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
35
35
|
import { createSceneOutliner, type SceneOutliner } from '@selvajs/visualization/scene';
|
|
36
36
|
import SceneManager from './SceneManager.svelte';
|
|
37
|
-
import
|
|
37
|
+
import MeshMetadataPanel from './MeshMetadataPanel.svelte';
|
|
38
38
|
import * as Resizable from '../primitives/resizable/index.js';
|
|
39
39
|
import * as DropdownMenu from '../primitives/dropdown-menu/index.js';
|
|
40
40
|
import { Button } from '../primitives/button/index.js';
|
|
@@ -122,6 +122,7 @@
|
|
|
122
122
|
let updateGridScale: (() => void) | null = null;
|
|
123
123
|
let updateShadowBounds: (() => void) | null = null;
|
|
124
124
|
let fitToView: (() => void) | null = null;
|
|
125
|
+
let clearSelection: (() => void) | null = null;
|
|
125
126
|
let viewerInitialized = false;
|
|
126
127
|
let sceneVersion = $state(0);
|
|
127
128
|
let hideButton = $state(false);
|
|
@@ -194,6 +195,10 @@
|
|
|
194
195
|
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
195
196
|
measure: { enabled: config.showToolsMenu },
|
|
196
197
|
events: {
|
|
198
|
+
// Fires before onMeshMetadataClicked, so picking an object without metadata closes the
|
|
199
|
+
// previous object's panel instead of leaving it describing the wrong thing.
|
|
200
|
+
onObjectSelected: closeMetadata,
|
|
201
|
+
onBackgroundClicked: closeMetadata,
|
|
197
202
|
onMeshMetadataClicked: config.enableMeshClick
|
|
198
203
|
? (metadata: Record<string, unknown>) => {
|
|
199
204
|
if (hasUsefulMetadata(metadata)) {
|
|
@@ -224,6 +229,7 @@
|
|
|
224
229
|
updateGridScale = init.updateGridScale;
|
|
225
230
|
updateShadowBounds = init.updateShadowBounds;
|
|
226
231
|
fitToView = init.fitToView;
|
|
232
|
+
clearSelection = init.clearSelection;
|
|
227
233
|
projection = init.cameraController.getProjection();
|
|
228
234
|
|
|
229
235
|
const renderer = init.renderer;
|
|
@@ -375,6 +381,29 @@
|
|
|
375
381
|
}
|
|
376
382
|
});
|
|
377
383
|
|
|
384
|
+
function closeMetadata() {
|
|
385
|
+
selectedMeshMetadata = null;
|
|
386
|
+
selectedMeshName = null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function dismissMetadata() {
|
|
390
|
+
closeMetadata();
|
|
391
|
+
clearSelection?.();
|
|
392
|
+
invalidate?.();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The panel describes an object from the previous solve once new meshes arrive.
|
|
396
|
+
$effect(() => {
|
|
397
|
+
void meshes;
|
|
398
|
+
untrack(closeMetadata);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Dialogs portal to <body>, so one opened over the fullscreen viewer needs lifting above it.
|
|
402
|
+
$effect(() => {
|
|
403
|
+
document.body.toggleAttribute('data-viewer-fullscreen', isFullscreen);
|
|
404
|
+
return () => document.body.removeAttribute('data-viewer-fullscreen');
|
|
405
|
+
});
|
|
406
|
+
|
|
378
407
|
function toggleFullscreen() {
|
|
379
408
|
isFullscreen = !isFullscreen;
|
|
380
409
|
}
|
|
@@ -450,12 +479,9 @@
|
|
|
450
479
|
{/if}
|
|
451
480
|
|
|
452
481
|
{#if logoUrl}
|
|
453
|
-
<!--
|
|
454
|
-
Always pointer-events:none so it never intercepts canvas drags. -->
|
|
482
|
+
<!-- pointer-events:none so it never intercepts canvas drags. -->
|
|
455
483
|
<div
|
|
456
|
-
class="right-4 {
|
|
457
|
-
? 'bottom-4'
|
|
458
|
-
: 'bottom-16 sm:bottom-4'} pointer-events-none absolute z-20 {hideButton
|
|
484
|
+
class="right-4 bottom-4 pointer-events-none absolute z-20 {hideButton
|
|
459
485
|
? 'opacity-0'
|
|
460
486
|
: ''}"
|
|
461
487
|
>
|
|
@@ -467,11 +493,18 @@
|
|
|
467
493
|
</div>
|
|
468
494
|
{/if}
|
|
469
495
|
|
|
496
|
+
{#if selectedMeshMetadata}
|
|
497
|
+
<MeshMetadataPanel
|
|
498
|
+
metadata={selectedMeshMetadata}
|
|
499
|
+
meshName={selectedMeshName}
|
|
500
|
+
onClose={dismissMetadata}
|
|
501
|
+
class="top-3 right-3 left-3 sm:right-auto sm:max-h-2/3 sm:w-72 absolute z-30 max-h-1/2"
|
|
502
|
+
/>
|
|
503
|
+
{/if}
|
|
504
|
+
|
|
470
505
|
{#if config.showToolsMenu}
|
|
471
506
|
<div
|
|
472
|
-
class="left-4 {
|
|
473
|
-
? 'bottom-4'
|
|
474
|
-
: 'bottom-16 sm:bottom-4'} shadow-lg absolute z-50 flex items-center rounded-lg {hideButton
|
|
507
|
+
class="left-4 bottom-4 gap-2 *:shadow-lg absolute z-50 flex items-center *:rounded-md {hideButton
|
|
475
508
|
? 'pointer-events-none opacity-0'
|
|
476
509
|
: ''}"
|
|
477
510
|
>
|
|
@@ -594,6 +627,18 @@
|
|
|
594
627
|
{/if}
|
|
595
628
|
</DropdownMenu.Content>
|
|
596
629
|
</DropdownMenu.Root>
|
|
630
|
+
<!-- Touch has no F key, and fit-to-view is the way back from a framed part. -->
|
|
631
|
+
<div class="hidden pointer-coarse:block">
|
|
632
|
+
<Button
|
|
633
|
+
variant="outline"
|
|
634
|
+
size="icon-lg"
|
|
635
|
+
title={t.fitToView}
|
|
636
|
+
aria-label={t.fitToView}
|
|
637
|
+
onclick={() => fitToView?.()}
|
|
638
|
+
>
|
|
639
|
+
<Frame />
|
|
640
|
+
</Button>
|
|
641
|
+
</div>
|
|
597
642
|
</div>
|
|
598
643
|
{/if}
|
|
599
644
|
</div>
|
|
@@ -608,22 +653,12 @@
|
|
|
608
653
|
</Resizable.PaneGroup>
|
|
609
654
|
</div>
|
|
610
655
|
|
|
611
|
-
<MeshMetadataDialog
|
|
612
|
-
open={selectedMeshMetadata !== null}
|
|
613
|
-
metadata={selectedMeshMetadata}
|
|
614
|
-
meshName={selectedMeshName}
|
|
615
|
-
{isFullscreen}
|
|
616
|
-
onOpenChange={(open: boolean) => {
|
|
617
|
-
if (!open) {
|
|
618
|
-
setTimeout(() => {
|
|
619
|
-
selectedMeshMetadata = null;
|
|
620
|
-
selectedMeshName = null;
|
|
621
|
-
}, 200);
|
|
622
|
-
}
|
|
623
|
-
}}
|
|
624
|
-
/>
|
|
625
|
-
|
|
626
656
|
<style>
|
|
657
|
+
:global([data-viewer-fullscreen] [data-slot='dialog-content']),
|
|
658
|
+
:global([data-viewer-fullscreen] [data-slot='dialog-overlay']) {
|
|
659
|
+
z-index: 10001;
|
|
660
|
+
}
|
|
661
|
+
|
|
627
662
|
.fullscreen-viewer {
|
|
628
663
|
position: fixed;
|
|
629
664
|
inset: 0;
|
package/dist/styles/base.css
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
:root {
|
|
4
4
|
--radius: 0.625rem;
|
|
5
5
|
--header-h: 3.5rem;
|
|
6
|
-
--page-px:
|
|
6
|
+
--page-px: 1rem;
|
|
7
7
|
--page-py: 1.75rem;
|
|
8
8
|
--background: oklch(0.99 0 0);
|
|
9
9
|
--foreground: oklch(0.22 0 0);
|
|
@@ -180,6 +180,26 @@
|
|
|
180
180
|
border-radius: inherit;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/* Phones keep a narrower gutter: at 390px every 8px of side padding is 2% of the width. */
|
|
184
|
+
@media (min-width: 40rem) {
|
|
185
|
+
:root {
|
|
186
|
+
--page-px: 1.5rem;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* A native select draws its arrow inside the right padding, so with the `px-2`/`px-3` every
|
|
191
|
+
caller uses it sits on the border. A drawn chevron with its own gutter fixes them all. Unlayered
|
|
192
|
+
on purpose: the gutter has to beat the callers' `px-*` utilities. Mid-grey reads on both themes,
|
|
193
|
+
since a data URI can't take `currentColor`. */
|
|
194
|
+
select:not([multiple]):not([size]) {
|
|
195
|
+
appearance: none;
|
|
196
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
|
197
|
+
background-repeat: no-repeat;
|
|
198
|
+
background-position: right 0.625rem center;
|
|
199
|
+
background-size: 1.1em;
|
|
200
|
+
padding-inline-end: calc(1.1em + 1rem);
|
|
201
|
+
}
|
|
202
|
+
|
|
183
203
|
@layer base {
|
|
184
204
|
* {
|
|
185
205
|
@apply border-border outline-ring;
|
package/dist/utils/loadScript.js
CHANGED
|
@@ -17,6 +17,8 @@ export function loadScript(src, options) {
|
|
|
17
17
|
script.src = src;
|
|
18
18
|
if (options?.crossOrigin)
|
|
19
19
|
script.crossOrigin = options.crossOrigin;
|
|
20
|
+
if (options?.integrity)
|
|
21
|
+
script.integrity = options.integrity;
|
|
20
22
|
if (options?.async !== undefined)
|
|
21
23
|
script.async = options.async;
|
|
22
24
|
if (options?.defer !== undefined)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "6.6.0-beta.
|
|
3
|
+
"version": "6.6.0-beta.7",
|
|
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.186.1",
|
|
62
|
-
"@selvajs/compute": "^4.
|
|
63
|
-
"@selvajs/schemas": "^5.
|
|
64
|
-
"@selvajs/
|
|
65
|
-
"@selvajs/
|
|
62
|
+
"@selvajs/compute": "^4.3.0-beta.1",
|
|
63
|
+
"@selvajs/schemas": "^5.3.0-beta.0",
|
|
64
|
+
"@selvajs/visualization": "^1.6.0-beta.0",
|
|
65
|
+
"@selvajs/solve": "^1.3.0-beta.3"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
68
|
"three": {
|
|
@@ -90,11 +90,11 @@
|
|
|
90
90
|
"svelte": "5.57.1",
|
|
91
91
|
"tailwind-variants": "^3.3.1",
|
|
92
92
|
"vitest": "^5.0.2",
|
|
93
|
-
"@selvajs/compute": "4.
|
|
93
|
+
"@selvajs/compute": "4.3.0-beta.1",
|
|
94
94
|
"@selvajs/config": "0.0.4",
|
|
95
|
-
"@selvajs/visualization": "1.
|
|
96
|
-
"@selvajs/solve": "1.3.0-beta.
|
|
97
|
-
"@selvajs/schemas": "5.
|
|
95
|
+
"@selvajs/visualization": "1.6.0-beta.0",
|
|
96
|
+
"@selvajs/solve": "1.3.0-beta.3",
|
|
97
|
+
"@selvajs/schemas": "5.3.0-beta.0"
|
|
98
98
|
},
|
|
99
99
|
"scripts": {
|
|
100
100
|
"predev": "node ../../scripts/sync-shared-assets.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
const t = $derived(locale.messages);
|
|
19
19
|
</script>
|
|
20
20
|
|
|
21
|
-
<div class="p-4 flex min-h-
|
|
21
|
+
<div class="p-4 flex min-h-dvh items-center justify-center bg-background">
|
|
22
22
|
<Card.Root class="max-w-md w-full">
|
|
23
23
|
<Card.Header>
|
|
24
24
|
<div class="gap-4 flex flex-col items-center text-center">
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
|
|
27
27
|
interface Props {
|
|
28
28
|
schema: UISchema;
|
|
29
|
+
/**
|
|
30
|
+
* Whether to draw the 3D viewer. Each host reads its own switch: the plugin's preview
|
|
31
|
+
* `viewerOptions.enableLocal`, a Rhino.Compute host `viewerOptions.enableRemote`.
|
|
32
|
+
*/
|
|
33
|
+
showViewer: boolean;
|
|
29
34
|
meshes?: any[];
|
|
30
35
|
isSolving: boolean;
|
|
31
36
|
showSolvingIndicator?: boolean;
|
|
@@ -54,6 +59,7 @@
|
|
|
54
59
|
|
|
55
60
|
let {
|
|
56
61
|
schema,
|
|
62
|
+
showViewer: hasViewer,
|
|
57
63
|
meshes = [],
|
|
58
64
|
isSolving,
|
|
59
65
|
showSolvingIndicator = false,
|
|
@@ -81,9 +87,6 @@
|
|
|
81
87
|
const hasMenu = $derived(showSaveButton || showLoadButton || panelActions.length > 0);
|
|
82
88
|
const resultIsStale = $derived(hasPendingChanges || hasNeverSolved);
|
|
83
89
|
|
|
84
|
-
const hasViewer = $derived(
|
|
85
|
-
!!(schema?.viewerOptions?.enableLocal || schema?.viewerOptions?.enableRemote)
|
|
86
|
-
);
|
|
87
90
|
const shownTabs = $derived(
|
|
88
91
|
schema.layout.type === 'tabbed'
|
|
89
92
|
? schema.layout.tabs.filter((tab) => isTabVisible(tab, values))
|
|
@@ -121,9 +124,14 @@
|
|
|
121
124
|
const isTwoPanelMode = $derived(!hasViewer && hasLeftPanel && hasRightPanel);
|
|
122
125
|
|
|
123
126
|
let isMobile = $state(false);
|
|
124
|
-
|
|
127
|
+
// Half keeps the model visible above the fields so a change can be watched as it solves;
|
|
128
|
+
// full is for long forms.
|
|
129
|
+
type DrawerState = 'closed' | 'half' | 'full';
|
|
130
|
+
let drawer = $state<DrawerState>('closed');
|
|
131
|
+
const DRAWER_STATES: DrawerState[] = ['closed', 'half', 'full'];
|
|
125
132
|
|
|
126
|
-
|
|
133
|
+
// With several tabs the first tab's name would mislabel whichever one is showing.
|
|
134
|
+
const drawerLabel = $derived(leftTabs.length === 1 ? leftTabs[0].label : t.parametersTab);
|
|
127
135
|
|
|
128
136
|
$effect(() => {
|
|
129
137
|
const mqMobile = window.matchMedia('(max-width: 639px)');
|
|
@@ -151,19 +159,31 @@
|
|
|
151
159
|
await onLoadValues?.(loadedValues);
|
|
152
160
|
}
|
|
153
161
|
|
|
154
|
-
let
|
|
162
|
+
let touchStartY = 0;
|
|
163
|
+
|
|
164
|
+
function stepDrawer(by: number) {
|
|
165
|
+
const index = DRAWER_STATES.indexOf(drawer) + by;
|
|
166
|
+
drawer = DRAWER_STATES[Math.max(0, Math.min(DRAWER_STATES.length - 1, index))];
|
|
167
|
+
}
|
|
155
168
|
|
|
156
169
|
function onDrawerTouchStart(e: TouchEvent) {
|
|
157
|
-
|
|
170
|
+
touchStartY = e.touches[0].clientY;
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
function onDrawerTouchEnd(e: TouchEvent) {
|
|
161
|
-
const deltaY = e.changedTouches[0].clientY -
|
|
174
|
+
const deltaY = e.changedTouches[0].clientY - touchStartY;
|
|
162
175
|
const SWIPE_THRESHOLD = 40;
|
|
163
176
|
if (Math.abs(deltaY) > SWIPE_THRESHOLD) {
|
|
164
|
-
|
|
177
|
+
// Suppresses the click a short swipe can still end in, which would step the drawer twice.
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
stepDrawer(deltaY < 0 ? 1 : -1);
|
|
165
180
|
}
|
|
166
181
|
}
|
|
182
|
+
|
|
183
|
+
// A tap walks closed → half → full → closed.
|
|
184
|
+
function onDrawerHandleClick() {
|
|
185
|
+
drawer = drawer === 'full' ? 'closed' : DRAWER_STATES[DRAWER_STATES.indexOf(drawer) + 1];
|
|
186
|
+
}
|
|
167
187
|
</script>
|
|
168
188
|
|
|
169
189
|
<!-- Everything but Calculate: saving and loading inputs, then the host's own actions. -->
|
|
@@ -276,16 +296,19 @@
|
|
|
276
296
|
: 'sm:pl-(--page-px)'} {rightCollapsed ? 'sm:pr-0' : 'sm:pr-(--page-px)'}"
|
|
277
297
|
class:fullscreen-layout={isViewerFullscreen}
|
|
278
298
|
class:relative={isMobile}
|
|
299
|
+
class:mobile-root={isMobile}
|
|
279
300
|
>
|
|
280
301
|
{#if isMobile}
|
|
281
302
|
{#if hasViewer}
|
|
282
|
-
|
|
303
|
+
<!-- Ends above the drawer rather than behind it, so the model stays centred in what is
|
|
304
|
+
visible. It jumps straight to its new size: animating it would resize the canvas,
|
|
305
|
+
and its render targets, on every frame of the drawer's slide. -->
|
|
306
|
+
<div class="viewer-region" class:viewer-region-raised={drawer !== 'closed'}>
|
|
283
307
|
<Viewer
|
|
284
308
|
{meshes}
|
|
285
309
|
bind:isFullscreen={isViewerFullscreen}
|
|
286
310
|
{isSolving}
|
|
287
|
-
|
|
288
|
-
{drawerOpen}
|
|
311
|
+
drawerOpen={drawer === 'full'}
|
|
289
312
|
{logoUrl}
|
|
290
313
|
{onViewerReady}
|
|
291
314
|
viewerConfig={{
|
|
@@ -297,31 +320,30 @@
|
|
|
297
320
|
</div>
|
|
298
321
|
|
|
299
322
|
<div
|
|
300
|
-
class="drawer-container rounded-t-2xl z-40 border-t bg-background shadow-sheet"
|
|
301
|
-
class:drawer-open={drawerOpen}
|
|
302
|
-
class:drawer-closed={!drawerOpen}
|
|
323
|
+
class="drawer-container rounded-t-2xl z-40 border-t bg-background shadow-sheet drawer-{drawer}"
|
|
303
324
|
>
|
|
304
325
|
<!-- Named by its visible label; aria-expanded carries the open/closed state. -->
|
|
305
326
|
<button
|
|
306
|
-
class="drawer-handle-bar"
|
|
307
|
-
onclick={
|
|
327
|
+
class="drawer-handle-bar touch-none"
|
|
328
|
+
onclick={onDrawerHandleClick}
|
|
308
329
|
ontouchstart={onDrawerTouchStart}
|
|
309
330
|
ontouchend={onDrawerTouchEnd}
|
|
310
|
-
aria-expanded={
|
|
331
|
+
aria-expanded={drawer !== 'closed'}
|
|
311
332
|
>
|
|
312
333
|
<div class="drawer-pill"></div>
|
|
313
|
-
<span class="text-sm font-medium ml-2 text-foreground">{
|
|
334
|
+
<span class="text-sm font-medium ml-2 text-foreground">{drawerLabel}</span>
|
|
314
335
|
<ChevronUp
|
|
315
336
|
size={16}
|
|
316
|
-
class="ml-auto text-muted-foreground transition-transform duration-300 {
|
|
317
|
-
|
|
318
|
-
|
|
337
|
+
class="ml-auto text-muted-foreground transition-transform duration-300 {drawer ===
|
|
338
|
+
'full'
|
|
339
|
+
? 'rotate-180'
|
|
340
|
+
: ''}"
|
|
319
341
|
/>
|
|
320
342
|
</button>
|
|
321
343
|
|
|
322
344
|
<!-- Inert while closed: the fields are clipped out of view, and tabbing into them
|
|
323
345
|
would scroll the drawer's insides around behind the handle. -->
|
|
324
|
-
<div class="drawer-content" inert={
|
|
346
|
+
<div class="drawer-content" inert={drawer === 'closed'}>
|
|
325
347
|
<div class="px-3 pb-6">
|
|
326
348
|
{#if hasLeftPanel}
|
|
327
349
|
{@render panelContent(hasRightPanel ? 'left' : undefined, requestedLeftTabId, false)}
|
|
@@ -340,7 +362,7 @@
|
|
|
340
362
|
</div>
|
|
341
363
|
</div>
|
|
342
364
|
|
|
343
|
-
{@render drawerFooter(
|
|
365
|
+
{@render drawerFooter(drawer === 'closed')}
|
|
344
366
|
</div>
|
|
345
367
|
{:else}
|
|
346
368
|
<div class="flex flex-1 flex-col overflow-hidden">
|
|
@@ -531,6 +553,22 @@
|
|
|
531
553
|
gap: 0.5rem;
|
|
532
554
|
}
|
|
533
555
|
|
|
556
|
+
.mobile-root {
|
|
557
|
+
--drawer-closed-h: 60px;
|
|
558
|
+
--drawer-half-h: 50%;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.viewer-region {
|
|
562
|
+
position: absolute;
|
|
563
|
+
inset: 0 0 var(--drawer-closed-h) 0;
|
|
564
|
+
display: flex;
|
|
565
|
+
flex-direction: column;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.viewer-region-raised {
|
|
569
|
+
bottom: var(--drawer-half-h);
|
|
570
|
+
}
|
|
571
|
+
|
|
534
572
|
.drawer-container {
|
|
535
573
|
position: absolute;
|
|
536
574
|
bottom: 0;
|
|
@@ -543,12 +581,14 @@
|
|
|
543
581
|
}
|
|
544
582
|
|
|
545
583
|
.drawer-closed {
|
|
546
|
-
height:
|
|
584
|
+
height: var(--drawer-closed-h);
|
|
585
|
+
}
|
|
586
|
+
.drawer-half {
|
|
587
|
+
height: var(--drawer-half-h);
|
|
547
588
|
}
|
|
548
|
-
/*
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
height: max(60svh, min(30rem, 100svh - 10rem));
|
|
589
|
+
/* Leaves a strip of the model showing, so the sheet still reads as sitting over the viewer. */
|
|
590
|
+
.drawer-full {
|
|
591
|
+
height: calc(100% - 3rem);
|
|
552
592
|
}
|
|
553
593
|
|
|
554
594
|
.drawer-handle-bar {
|
|
@@ -74,14 +74,16 @@
|
|
|
74
74
|
|
|
75
75
|
const rootClass = $derived(
|
|
76
76
|
_mode === 'fixed'
|
|
77
|
-
?
|
|
78
|
-
|
|
77
|
+
? // dvh, not vh: iOS sizes 100vh to the viewport without its toolbar, which hides the
|
|
78
|
+
// footer and the phone drawer's handle behind the toolbar.
|
|
79
|
+
`flex flex-col h-dvh overflow-hidden bg-background ${className}`
|
|
80
|
+
: `flex flex-col min-h-dvh bg-background ${className}`
|
|
79
81
|
);
|
|
80
82
|
|
|
81
83
|
const bodyShellClass = $derived(
|
|
82
84
|
_mode === 'fixed'
|
|
83
|
-
? `flex flex-1 min-h-0 ${sidenav ? 'flex-row' : 'flex-col'} ${bodyClass}`.trim()
|
|
84
|
-
: `flex-1 ${sidenav ? 'flex flex-row' : ''} ${bodyClass}`.trim()
|
|
85
|
+
? `flex flex-1 min-h-0 ${sidenav ? 'flex-col sm:flex-row' : 'flex-col'} ${bodyClass}`.trim()
|
|
86
|
+
: `flex-1 ${sidenav ? 'flex flex-col sm:flex-row' : ''} ${bodyClass}`.trim()
|
|
85
87
|
);
|
|
86
88
|
|
|
87
89
|
const bodyShellStyle = '';
|
|
@@ -92,13 +94,15 @@
|
|
|
92
94
|
const sidenavWrapClass = $derived(
|
|
93
95
|
_mode === 'fixed'
|
|
94
96
|
? 'flex shrink-0'
|
|
95
|
-
: 'flex shrink-0 self-start sticky top-(--header-h) max-h-[calc(100svh-var(--header-h))]'
|
|
97
|
+
: 'flex shrink-0 sm:self-start sm:sticky sm:top-(--header-h) sm:max-h-[calc(100svh-var(--header-h))]'
|
|
96
98
|
);
|
|
97
99
|
|
|
98
100
|
// The sticky wrapper is only as tall as its content, so in scroll mode the divider comes
|
|
99
101
|
// from the main column's left edge — that one spans the full body height.
|
|
100
102
|
const mainClass = $derived(
|
|
101
|
-
_mode === 'fixed'
|
|
103
|
+
_mode === 'fixed'
|
|
104
|
+
? 'min-h-0 flex-1 overflow-y-auto'
|
|
105
|
+
: 'min-w-0 flex-1 overflow-y-auto border-border sm:border-l'
|
|
102
106
|
);
|
|
103
107
|
</script>
|
|
104
108
|
|
|
@@ -31,27 +31,31 @@
|
|
|
31
31
|
<header
|
|
32
32
|
class={`top-0 backdrop-blur-sm sticky z-40 border-b border-border bg-background/90 ${className}`}
|
|
33
33
|
>
|
|
34
|
-
<div class="gap-5 flex h-(--header-h) items-center px-(--page-px)">
|
|
34
|
+
<div class="gap-2 sm:gap-5 flex h-(--header-h) items-center px-(--page-px)">
|
|
35
35
|
<!-- Logo -->
|
|
36
|
-
<a href={homeUrl} class="gap-2 flex shrink-0 items-center">
|
|
36
|
+
<a href={homeUrl} class="gap-2 flex shrink-0 items-center" aria-label={brandName}>
|
|
37
37
|
<img src={logo} alt="" aria-hidden="true" class="h-5 w-5" />
|
|
38
|
-
|
|
38
|
+
<!-- On a phone the page title needs the room more than the brand does. -->
|
|
39
|
+
<span
|
|
40
|
+
class="font-semibold text-sm tracking-tight {title !== undefined ? 'sm:inline hidden' : ''}"
|
|
41
|
+
>{brandName}</span
|
|
42
|
+
>
|
|
39
43
|
</a>
|
|
40
44
|
|
|
41
45
|
{#if title !== undefined}
|
|
42
|
-
<span class="text-sm text-border">/</span>
|
|
43
|
-
<span class="text-sm font-medium text-muted-foreground">{title ?? ''}</span>
|
|
46
|
+
<span class="text-sm shrink-0 text-border">/</span>
|
|
47
|
+
<span class="text-sm font-medium min-w-0 truncate text-muted-foreground">{title ?? ''}</span>
|
|
44
48
|
{/if}
|
|
45
49
|
|
|
46
50
|
<!-- Primary nav next to logo -->
|
|
47
51
|
{#if navItems}
|
|
48
|
-
<nav class="ml-5 gap-1 flex">
|
|
52
|
+
<nav class="sm:ml-5 gap-1 flex shrink-0">
|
|
49
53
|
{@render navItems()}
|
|
50
54
|
</nav>
|
|
51
55
|
{/if}
|
|
52
56
|
|
|
53
57
|
<!-- Right cluster: identity + utilities, anchored flush-right -->
|
|
54
|
-
<div class="gap-2 ml-auto flex items-center">
|
|
58
|
+
<div class="gap-2 ml-auto flex shrink-0 items-center">
|
|
55
59
|
{#if rightContent}
|
|
56
60
|
{@render rightContent()}
|
|
57
61
|
{/if}
|