@selvajs/ui 6.1.2 → 6.2.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/SceneManager.svelte +28 -12
- package/dist/components/viewer/SceneManager.svelte.d.ts +6 -0
- package/dist/components/viewer/Viewer.svelte +16 -18
- package/package.json +7 -7
- 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/SceneManager.svelte +28 -12
- package/src/lib/components/viewer/Viewer.svelte +16 -18
|
@@ -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>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
open?: boolean;
|
|
3
|
+
title: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
confirmLabel?: string;
|
|
6
|
+
pendingLabel?: string;
|
|
7
|
+
cancelLabel?: string;
|
|
8
|
+
variant?: 'default' | 'destructive';
|
|
9
|
+
onConfirm: () => void | Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
declare const ConfirmDialog: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
12
|
+
type ConfirmDialog = ReturnType<typeof ConfirmDialog>;
|
|
13
|
+
export default ConfirmDialog;
|
|
@@ -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}
|
|
@@ -56,6 +56,9 @@ interface Props {
|
|
|
56
56
|
/** Viewer chrome and defaults. `backgroundColor` and `showSceneManager` are set by the layout. */
|
|
57
57
|
viewerConfig?: ViewerConfig;
|
|
58
58
|
headerRight?: Snippet;
|
|
59
|
+
navItems?: Snippet;
|
|
60
|
+
homeUrl?: string;
|
|
61
|
+
brandName?: string;
|
|
59
62
|
header?: Snippet;
|
|
60
63
|
externalScopeKey?: string;
|
|
61
64
|
clientSlot?: ClientSlot;
|
|
@@ -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">
|
|
@@ -12,6 +12,11 @@ interface SideNavProps {
|
|
|
12
12
|
eyebrow?: string;
|
|
13
13
|
header?: Snippet;
|
|
14
14
|
footer?: Snippet;
|
|
15
|
+
/**
|
|
16
|
+
* Off when the divider is drawn by the surrounding layout instead — a sticky sidenav
|
|
17
|
+
* only reaches as far as its own content, so its border would stop mid-page.
|
|
18
|
+
*/
|
|
19
|
+
border?: boolean;
|
|
15
20
|
class?: string;
|
|
16
21
|
}
|
|
17
22
|
declare const SideNav: Component<SideNavProps, {}, "">;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Input } from '../../primitives/input';
|
|
3
3
|
import { Button } from '../../primitives/button';
|
|
4
4
|
import { Label } from '../../primitives/label';
|
|
5
|
+
import { toast } from '../../primitives/sonner';
|
|
5
6
|
import { FileUp, Link, CircleAlert, CircleCheck } from '@lucide/svelte';
|
|
6
7
|
import { APP_DEFAULTS } from '../../../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 '../../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>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type VariantProps } from 'tailwind-variants';
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
export declare const calloutVariants: import("tailwind-variants").TVReturnType<{
|
|
4
|
+
tone: {
|
|
5
|
+
info: "border-info/40 bg-info/10";
|
|
6
|
+
tip: "border-border bg-muted/40";
|
|
7
|
+
success: "border-success/40 bg-success/10";
|
|
8
|
+
warning: "border-warning/40 bg-warning/10";
|
|
9
|
+
danger: "border-destructive/40 bg-destructive/10";
|
|
10
|
+
};
|
|
11
|
+
}, undefined, "flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-sm", {
|
|
12
|
+
tone: {
|
|
13
|
+
info: "border-info/40 bg-info/10";
|
|
14
|
+
tip: "border-border bg-muted/40";
|
|
15
|
+
success: "border-success/40 bg-success/10";
|
|
16
|
+
warning: "border-warning/40 bg-warning/10";
|
|
17
|
+
danger: "border-destructive/40 bg-destructive/10";
|
|
18
|
+
};
|
|
19
|
+
}, undefined, import("tailwind-variants").TVReturnTypeLike<{
|
|
20
|
+
tone: {
|
|
21
|
+
info: "border-info/40 bg-info/10";
|
|
22
|
+
tip: "border-border bg-muted/40";
|
|
23
|
+
success: "border-success/40 bg-success/10";
|
|
24
|
+
warning: "border-warning/40 bg-warning/10";
|
|
25
|
+
danger: "border-destructive/40 bg-destructive/10";
|
|
26
|
+
};
|
|
27
|
+
}, undefined>>;
|
|
28
|
+
export type CalloutTone = NonNullable<VariantProps<typeof calloutVariants>['tone']>;
|
|
29
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
30
|
+
import type { Snippet } from 'svelte';
|
|
31
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
32
|
+
tone?: CalloutTone;
|
|
33
|
+
title?: string;
|
|
34
|
+
/** Replaces the tone's default icon. `null` drops the icon entirely. */
|
|
35
|
+
icon?: Component | null;
|
|
36
|
+
children: Snippet;
|
|
37
|
+
}
|
|
38
|
+
declare const Callout: Component<Props, {}, "">;
|
|
39
|
+
type Callout = ReturnType<typeof Callout>;
|
|
40
|
+
export default Callout;
|
|
@@ -22,9 +22,11 @@ export { Switch } from './switch';
|
|
|
22
22
|
export { Toaster, toast } from './sonner';
|
|
23
23
|
export { ThemeSwitcher } from './theme-switcher';
|
|
24
24
|
export { default as StateDisplay } from './StateDisplay.svelte';
|
|
25
|
+
export { default as Callout, calloutVariants, type CalloutTone } from './Callout.svelte';
|
|
25
26
|
export { default as CalculateButton } from './CalculateButton.svelte';
|
|
26
27
|
export { ModeToggle } from './mode-toggle';
|
|
27
28
|
export { default as ViewToggle } from './ViewToggle.svelte';
|
|
28
29
|
export { default as ImageUploadField } from './ImageUploadField.svelte';
|
|
29
30
|
export { default as DataTable, type DataTableColumn } from './DataTable.svelte';
|
|
30
31
|
export { default as FilterableDropdown, type FilterableDropdownItem } from './FilterableDropdown.svelte';
|
|
32
|
+
export { default as ConfirmDialog } from '../ConfirmDialog.svelte';
|
|
@@ -24,9 +24,11 @@ export { Toaster, toast } from './sonner';
|
|
|
24
24
|
export { ThemeSwitcher } from './theme-switcher';
|
|
25
25
|
// Custom components (not replaced by shadcn)
|
|
26
26
|
export { default as StateDisplay } from './StateDisplay.svelte';
|
|
27
|
+
export { default as Callout, calloutVariants } from './Callout.svelte';
|
|
27
28
|
export { default as CalculateButton } from './CalculateButton.svelte';
|
|
28
29
|
export { ModeToggle } from './mode-toggle';
|
|
29
30
|
export { default as ViewToggle } from './ViewToggle.svelte';
|
|
30
31
|
export { default as ImageUploadField } from './ImageUploadField.svelte';
|
|
31
32
|
export { default as DataTable } from './DataTable.svelte';
|
|
32
33
|
export { default as FilterableDropdown } from './FilterableDropdown.svelte';
|
|
34
|
+
export { default as ConfirmDialog } from '../ConfirmDialog.svelte';
|
|
@@ -20,17 +20,34 @@
|
|
|
20
20
|
*/
|
|
21
21
|
outliner: SceneOutliner;
|
|
22
22
|
sceneVersion?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Request a viewer repaint. Toggling `.visible` mutates three objects directly, and the
|
|
25
|
+
* render loop is on-demand — without this the canvas only catches up on its ~500ms idle
|
|
26
|
+
* repaint, so the row updates instantly and the geometry lags behind.
|
|
27
|
+
*/
|
|
28
|
+
onVisibilityChange?: () => void;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
let { outliner, sceneVersion = 0 }: Props = $props();
|
|
31
|
+
let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
|
|
32
|
+
|
|
33
|
+
const toggleLayer = (objects: THREE.Object3D[]) => {
|
|
34
|
+
outliner.visibility.toggleLayer(objects);
|
|
35
|
+
onVisibilityChange?.();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const toggleObject = (object: THREE.Object3D) => {
|
|
39
|
+
outliner.toggleObject(object);
|
|
40
|
+
onVisibilityChange?.();
|
|
41
|
+
};
|
|
26
42
|
|
|
27
43
|
// Derived, not destructured: the prop is reassignable, and these must follow it.
|
|
28
44
|
const hidden = $derived(outliner.visibility.hidden);
|
|
29
45
|
const selected = $derived(outliner.selection.selected);
|
|
30
46
|
const collapsed = $derived(outliner.collapsed);
|
|
31
47
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
48
|
+
// Panel state, so reopening clears it — unlike hiding and collapse, which the outliner outlives.
|
|
49
|
+
// Owned here rather than on the outliner: writing it back from the derived below is what the
|
|
50
|
+
// Svelte 5 `state_unsafe_mutation` rule forbids.
|
|
34
51
|
let searchQuery = $state('');
|
|
35
52
|
let anchor = $state<string | null>(null);
|
|
36
53
|
|
|
@@ -45,9 +62,7 @@
|
|
|
45
62
|
|
|
46
63
|
const layerGroups = $derived.by(() => {
|
|
47
64
|
void sceneVersion;
|
|
48
|
-
|
|
49
|
-
outliner.searchQuery = searchQuery;
|
|
50
|
-
return outliner.layerGroups();
|
|
65
|
+
return outliner.layerGroups(searchQuery);
|
|
51
66
|
});
|
|
52
67
|
|
|
53
68
|
// `SvelteSet.has()` is the reactive read, so go through the set rather than calling
|
|
@@ -62,10 +77,11 @@
|
|
|
62
77
|
// Reading `anchor` keeps the shift-range dependent on it; the outliner owns the value.
|
|
63
78
|
const selectObject = (uuid: string, event: MouseEvent) => {
|
|
64
79
|
void anchor;
|
|
65
|
-
outliner.select(
|
|
66
|
-
|
|
67
|
-
toggleKey: event.ctrlKey || event.metaKey
|
|
68
|
-
|
|
80
|
+
outliner.select(
|
|
81
|
+
uuid,
|
|
82
|
+
{ shiftKey: event.shiftKey, toggleKey: event.ctrlKey || event.metaKey },
|
|
83
|
+
searchQuery
|
|
84
|
+
);
|
|
69
85
|
};
|
|
70
86
|
</script>
|
|
71
87
|
|
|
@@ -110,7 +126,7 @@
|
|
|
110
126
|
|
|
111
127
|
<button
|
|
112
128
|
class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
|
|
113
|
-
onclick={() =>
|
|
129
|
+
onclick={() => toggleLayer(objects)}
|
|
114
130
|
title={layerHidden ? t.showLayer : t.hideLayer}
|
|
115
131
|
aria-label={layerHidden ? t.showLayer : t.hideLayer}
|
|
116
132
|
>
|
|
@@ -162,7 +178,7 @@
|
|
|
162
178
|
class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
|
|
163
179
|
onclick={(e) => {
|
|
164
180
|
e.stopPropagation();
|
|
165
|
-
|
|
181
|
+
toggleObject(object);
|
|
166
182
|
}}
|
|
167
183
|
title={isHidden ? t.showObject : t.hideObject}
|
|
168
184
|
aria-label={isHidden ? t.showObject : t.hideObject}
|
|
@@ -7,6 +7,12 @@ interface Props {
|
|
|
7
7
|
*/
|
|
8
8
|
outliner: SceneOutliner;
|
|
9
9
|
sceneVersion?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Request a viewer repaint. Toggling `.visible` mutates three objects directly, and the
|
|
12
|
+
* render loop is on-demand — without this the canvas only catches up on its ~500ms idle
|
|
13
|
+
* repaint, so the row updates instantly and the geometry lags behind.
|
|
14
|
+
*/
|
|
15
|
+
onVisibilityChange?: () => void;
|
|
10
16
|
}
|
|
11
17
|
declare const SceneManager: import("svelte").Component<Props, {}, "">;
|
|
12
18
|
type SceneManager = ReturnType<typeof SceneManager>;
|
|
@@ -121,6 +121,7 @@
|
|
|
121
121
|
let applyEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
122
122
|
let clearEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
123
123
|
let invalidate: (() => void) | null = null;
|
|
124
|
+
let captureImage: ThreeViewer['captureImage'] | null = null;
|
|
124
125
|
let setLook: ((look: Look) => void) | null = null;
|
|
125
126
|
let updateGridScale: (() => void) | null = null;
|
|
126
127
|
let fitToView: (() => void) | null = null;
|
|
@@ -210,6 +211,7 @@
|
|
|
210
211
|
applyEdges = init.applyEdges;
|
|
211
212
|
clearEdges = init.clearEdges;
|
|
212
213
|
invalidate = init.invalidate;
|
|
214
|
+
captureImage = init.captureImage;
|
|
213
215
|
setLook = init.setLook;
|
|
214
216
|
updateGridScale = init.updateGridScale;
|
|
215
217
|
fitToView = init.fitToView;
|
|
@@ -322,23 +324,19 @@
|
|
|
322
324
|
return usefulEntries.length > 0;
|
|
323
325
|
}
|
|
324
326
|
|
|
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
|
-
});
|
|
327
|
+
async function downloadScreenshot() {
|
|
328
|
+
const blob = await captureImage?.();
|
|
329
|
+
if (!blob) return;
|
|
330
|
+
|
|
331
|
+
const url = URL.createObjectURL(blob);
|
|
332
|
+
const link = document.createElement('a');
|
|
333
|
+
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
|
334
|
+
link.href = url;
|
|
335
|
+
link.download = `viewer-${timestamp}.png`;
|
|
336
|
+
document.body.appendChild(link);
|
|
337
|
+
link.click();
|
|
338
|
+
document.body.removeChild(link);
|
|
339
|
+
URL.revokeObjectURL(url);
|
|
342
340
|
}
|
|
343
341
|
</script>
|
|
344
342
|
|
|
@@ -569,7 +567,7 @@
|
|
|
569
567
|
{#if sceneManagerOpen && scene && outliner}
|
|
570
568
|
<Resizable.Handle withHandle />
|
|
571
569
|
<Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
|
|
572
|
-
<SceneManager {outliner} {sceneVersion} />
|
|
570
|
+
<SceneManager {outliner} {sceneVersion} onVisibilityChange={() => invalidate?.()} />
|
|
573
571
|
</Resizable.Pane>
|
|
574
572
|
{/if}
|
|
575
573
|
</Resizable.PaneGroup>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.2.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/schemas": "^5.0.1",
|
|
63
|
+
"@selvajs/solve": "^1.0.5",
|
|
62
64
|
"@selvajs/compute": "^4.0.2",
|
|
63
|
-
"@selvajs/
|
|
64
|
-
"@selvajs/visualization": "^1.0.1",
|
|
65
|
-
"@selvajs/schemas": "^5.0.1"
|
|
65
|
+
"@selvajs/visualization": "^1.1.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
68
|
"three": {
|
|
@@ -90,11 +90,11 @@
|
|
|
90
90
|
"svelte": "5.56.9",
|
|
91
91
|
"tailwind-variants": "^3.3.1",
|
|
92
92
|
"vitest": "^4.1.10",
|
|
93
|
+
"@selvajs/compute": "4.0.2",
|
|
93
94
|
"@selvajs/config": "0.0.4",
|
|
94
95
|
"@selvajs/schemas": "5.0.1",
|
|
95
|
-
"@selvajs/
|
|
96
|
-
"@selvajs/
|
|
97
|
-
"@selvajs/compute": "4.0.2"
|
|
96
|
+
"@selvajs/solve": "1.0.5",
|
|
97
|
+
"@selvajs/visualization": "1.1.0"
|
|
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';
|
|
@@ -20,17 +20,34 @@
|
|
|
20
20
|
*/
|
|
21
21
|
outliner: SceneOutliner;
|
|
22
22
|
sceneVersion?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Request a viewer repaint. Toggling `.visible` mutates three objects directly, and the
|
|
25
|
+
* render loop is on-demand — without this the canvas only catches up on its ~500ms idle
|
|
26
|
+
* repaint, so the row updates instantly and the geometry lags behind.
|
|
27
|
+
*/
|
|
28
|
+
onVisibilityChange?: () => void;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
let { outliner, sceneVersion = 0 }: Props = $props();
|
|
31
|
+
let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
|
|
32
|
+
|
|
33
|
+
const toggleLayer = (objects: THREE.Object3D[]) => {
|
|
34
|
+
outliner.visibility.toggleLayer(objects);
|
|
35
|
+
onVisibilityChange?.();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const toggleObject = (object: THREE.Object3D) => {
|
|
39
|
+
outliner.toggleObject(object);
|
|
40
|
+
onVisibilityChange?.();
|
|
41
|
+
};
|
|
26
42
|
|
|
27
43
|
// Derived, not destructured: the prop is reassignable, and these must follow it.
|
|
28
44
|
const hidden = $derived(outliner.visibility.hidden);
|
|
29
45
|
const selected = $derived(outliner.selection.selected);
|
|
30
46
|
const collapsed = $derived(outliner.collapsed);
|
|
31
47
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
48
|
+
// Panel state, so reopening clears it — unlike hiding and collapse, which the outliner outlives.
|
|
49
|
+
// Owned here rather than on the outliner: writing it back from the derived below is what the
|
|
50
|
+
// Svelte 5 `state_unsafe_mutation` rule forbids.
|
|
34
51
|
let searchQuery = $state('');
|
|
35
52
|
let anchor = $state<string | null>(null);
|
|
36
53
|
|
|
@@ -45,9 +62,7 @@
|
|
|
45
62
|
|
|
46
63
|
const layerGroups = $derived.by(() => {
|
|
47
64
|
void sceneVersion;
|
|
48
|
-
|
|
49
|
-
outliner.searchQuery = searchQuery;
|
|
50
|
-
return outliner.layerGroups();
|
|
65
|
+
return outliner.layerGroups(searchQuery);
|
|
51
66
|
});
|
|
52
67
|
|
|
53
68
|
// `SvelteSet.has()` is the reactive read, so go through the set rather than calling
|
|
@@ -62,10 +77,11 @@
|
|
|
62
77
|
// Reading `anchor` keeps the shift-range dependent on it; the outliner owns the value.
|
|
63
78
|
const selectObject = (uuid: string, event: MouseEvent) => {
|
|
64
79
|
void anchor;
|
|
65
|
-
outliner.select(
|
|
66
|
-
|
|
67
|
-
toggleKey: event.ctrlKey || event.metaKey
|
|
68
|
-
|
|
80
|
+
outliner.select(
|
|
81
|
+
uuid,
|
|
82
|
+
{ shiftKey: event.shiftKey, toggleKey: event.ctrlKey || event.metaKey },
|
|
83
|
+
searchQuery
|
|
84
|
+
);
|
|
69
85
|
};
|
|
70
86
|
</script>
|
|
71
87
|
|
|
@@ -110,7 +126,7 @@
|
|
|
110
126
|
|
|
111
127
|
<button
|
|
112
128
|
class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
|
|
113
|
-
onclick={() =>
|
|
129
|
+
onclick={() => toggleLayer(objects)}
|
|
114
130
|
title={layerHidden ? t.showLayer : t.hideLayer}
|
|
115
131
|
aria-label={layerHidden ? t.showLayer : t.hideLayer}
|
|
116
132
|
>
|
|
@@ -162,7 +178,7 @@
|
|
|
162
178
|
class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
|
|
163
179
|
onclick={(e) => {
|
|
164
180
|
e.stopPropagation();
|
|
165
|
-
|
|
181
|
+
toggleObject(object);
|
|
166
182
|
}}
|
|
167
183
|
title={isHidden ? t.showObject : t.hideObject}
|
|
168
184
|
aria-label={isHidden ? t.showObject : t.hideObject}
|
|
@@ -121,6 +121,7 @@
|
|
|
121
121
|
let applyEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
122
122
|
let clearEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
123
123
|
let invalidate: (() => void) | null = null;
|
|
124
|
+
let captureImage: ThreeViewer['captureImage'] | null = null;
|
|
124
125
|
let setLook: ((look: Look) => void) | null = null;
|
|
125
126
|
let updateGridScale: (() => void) | null = null;
|
|
126
127
|
let fitToView: (() => void) | null = null;
|
|
@@ -210,6 +211,7 @@
|
|
|
210
211
|
applyEdges = init.applyEdges;
|
|
211
212
|
clearEdges = init.clearEdges;
|
|
212
213
|
invalidate = init.invalidate;
|
|
214
|
+
captureImage = init.captureImage;
|
|
213
215
|
setLook = init.setLook;
|
|
214
216
|
updateGridScale = init.updateGridScale;
|
|
215
217
|
fitToView = init.fitToView;
|
|
@@ -322,23 +324,19 @@
|
|
|
322
324
|
return usefulEntries.length > 0;
|
|
323
325
|
}
|
|
324
326
|
|
|
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
|
-
});
|
|
327
|
+
async function downloadScreenshot() {
|
|
328
|
+
const blob = await captureImage?.();
|
|
329
|
+
if (!blob) return;
|
|
330
|
+
|
|
331
|
+
const url = URL.createObjectURL(blob);
|
|
332
|
+
const link = document.createElement('a');
|
|
333
|
+
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
|
334
|
+
link.href = url;
|
|
335
|
+
link.download = `viewer-${timestamp}.png`;
|
|
336
|
+
document.body.appendChild(link);
|
|
337
|
+
link.click();
|
|
338
|
+
document.body.removeChild(link);
|
|
339
|
+
URL.revokeObjectURL(url);
|
|
342
340
|
}
|
|
343
341
|
</script>
|
|
344
342
|
|
|
@@ -569,7 +567,7 @@
|
|
|
569
567
|
{#if sceneManagerOpen && scene && outliner}
|
|
570
568
|
<Resizable.Handle withHandle />
|
|
571
569
|
<Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
|
|
572
|
-
<SceneManager {outliner} {sceneVersion} />
|
|
570
|
+
<SceneManager {outliner} {sceneVersion} onVisibilityChange={() => invalidate?.()} />
|
|
573
571
|
</Resizable.Pane>
|
|
574
572
|
{/if}
|
|
575
573
|
</Resizable.PaneGroup>
|