@selvajs/ui 6.6.0-beta.8 → 6.6.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/compute/AppLayout.svelte +37 -7
- package/dist/components/compute/AppLayout.svelte.d.ts +5 -0
- package/dist/components/compute/ParameterPresetManager.svelte +8 -20
- package/dist/components/compute/SolveMessages.svelte +26 -3
- package/dist/components/compute/SolveMessages.svelte.d.ts +7 -0
- package/dist/components/layout/PageFooter.svelte +3 -1
- package/dist/components/layout/SideNav.svelte +55 -14
- package/dist/components/preview/ChartOutput.svelte +18 -13
- package/dist/components/preview/Group.svelte +6 -1
- package/dist/components/preview/ImageOutput.svelte +3 -1
- package/dist/components/preview/InputControl.svelte +108 -44
- package/dist/components/preview/InputControl.svelte.d.ts +5 -1
- package/dist/components/preview/TabContent.svelte +61 -29
- package/dist/components/preview/TabContent.svelte.d.ts +5 -1
- package/dist/components/preview/TabLayout.svelte +25 -3
- package/dist/components/preview/TabLayout.svelte.d.ts +5 -0
- package/dist/components/preview/inputs/ChecklistInput.svelte +7 -2
- package/dist/components/preview/inputs/ChecklistInput.svelte.d.ts +2 -1
- package/dist/components/preview/inputs/DropdownInput.svelte +16 -4
- package/dist/components/preview/inputs/DropdownInput.svelte.d.ts +2 -1
- package/dist/components/preview/inputs/FileInput.svelte +56 -65
- package/dist/components/preview/inputs/GeometryInput.svelte +128 -0
- package/dist/components/preview/inputs/GeometryInput.svelte.d.ts +21 -0
- package/dist/components/preview/inputs/OptionGlyph.svelte +29 -0
- package/dist/components/preview/inputs/OptionGlyph.svelte.d.ts +8 -0
- package/dist/components/preview/inputs/SegmentedInput.svelte +44 -0
- package/dist/components/preview/inputs/SegmentedInput.svelte.d.ts +12 -0
- package/dist/components/preview/inputs/TileInput.svelte +80 -0
- package/dist/components/preview/inputs/TileInput.svelte.d.ts +12 -0
- package/dist/components/preview/inputs/index.d.ts +3 -0
- package/dist/components/preview/inputs/index.js +3 -0
- package/dist/components/primitives/SolvingIndicator.svelte +28 -3
- package/dist/components/primitives/SolvingIndicator.svelte.d.ts +7 -0
- package/dist/components/primitives/button/button.svelte +3 -0
- package/dist/components/primitives/button/button.svelte.d.ts +3 -0
- package/dist/components/primitives/toggle/toggle.svelte +3 -1
- package/dist/components/primitives/toggle/toggle.svelte.d.ts +3 -0
- package/dist/components/viewer/Viewer.svelte +11 -11
- package/dist/compute/solving.svelte.d.ts +7 -1
- package/dist/compute/solving.svelte.js +12 -1
- package/dist/contexts/hostContext.svelte.d.ts +39 -0
- package/dist/contexts/hostContext.svelte.js +9 -0
- package/dist/i18n/messages.d.ts +33 -0
- package/dist/i18n/messages.js +58 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/schema/host-support.d.ts +29 -0
- package/dist/schema/host-support.js +97 -0
- package/dist/schema/option-display.d.ts +12 -0
- package/dist/schema/option-display.js +29 -0
- package/dist/schema/param-exporter.d.ts +4 -1
- package/dist/schema/param-exporter.js +10 -6
- package/dist/styles/base.css +6 -3
- package/dist/utils/file-download.js +6 -10
- package/dist/utils/host-files.d.ts +12 -0
- package/dist/utils/host-files.js +46 -0
- package/dist/utils/webp-to-png.d.ts +10 -0
- package/dist/utils/webp-to-png.js +41 -0
- package/package.json +9 -9
- package/src/lib/components/compute/AppLayout.svelte +37 -7
- package/src/lib/components/compute/ParameterPresetManager.svelte +8 -20
- package/src/lib/components/compute/SolveMessages.svelte +26 -3
- package/src/lib/components/layout/PageFooter.svelte +3 -1
- package/src/lib/components/layout/SideNav.svelte +55 -14
- package/src/lib/components/preview/ChartOutput.svelte +18 -13
- package/src/lib/components/preview/Group.svelte +6 -1
- package/src/lib/components/preview/ImageOutput.svelte +3 -1
- package/src/lib/components/preview/InputControl.svelte +108 -44
- package/src/lib/components/preview/TabContent.svelte +61 -29
- package/src/lib/components/preview/TabLayout.svelte +25 -3
- package/src/lib/components/preview/inputs/ChecklistInput.svelte +7 -2
- package/src/lib/components/preview/inputs/DropdownInput.svelte +16 -4
- package/src/lib/components/preview/inputs/FileInput.svelte +56 -65
- package/src/lib/components/preview/inputs/GeometryInput.svelte +128 -0
- package/src/lib/components/preview/inputs/OptionGlyph.svelte +29 -0
- package/src/lib/components/preview/inputs/SegmentedInput.svelte +44 -0
- package/src/lib/components/preview/inputs/TileInput.svelte +80 -0
- package/src/lib/components/preview/inputs/index.ts +3 -0
- package/src/lib/components/primitives/SolvingIndicator.svelte +28 -3
- package/src/lib/components/primitives/button/button.svelte +3 -0
- package/src/lib/components/primitives/toggle/toggle.svelte +3 -1
- package/src/lib/components/viewer/Viewer.svelte +11 -11
- package/src/lib/compute/solving.svelte.ts +16 -1
- package/src/lib/contexts/hostContext.svelte.ts +51 -0
- package/src/lib/i18n/messages.ts +96 -0
- package/src/lib/index.ts +20 -0
- package/src/lib/schema/host-support.ts +129 -0
- package/src/lib/schema/option-display.ts +44 -0
- package/src/lib/schema/param-exporter.ts +10 -7
- package/src/lib/styles/base.css +6 -3
- package/src/lib/utils/file-download.ts +7 -11
- package/src/lib/utils/host-files.ts +61 -0
- package/src/lib/utils/webp-to-png.ts +48 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from '$lib/components/primitives/button';
|
|
3
|
+
import type { GeometryPickResult, PickKind } from '$lib/contexts/hostContext.svelte';
|
|
4
|
+
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
5
|
+
import type { ViewerMessages } from '$lib/i18n/messages';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
inputId: string;
|
|
9
|
+
/** The kinds of object the input takes; one kind names the button after it. */
|
|
10
|
+
kinds?: readonly PickKind[];
|
|
11
|
+
/** 1 picks a single object. */
|
|
12
|
+
atMost?: number;
|
|
13
|
+
/** What the host reports the param holds, such as "2 curves"; absent until something is picked. */
|
|
14
|
+
summary?: string;
|
|
15
|
+
/** Absent where this page may not pick, such as a browser tab beside the Rhino panel. */
|
|
16
|
+
onPick?: () => Promise<GeometryPickResult>;
|
|
17
|
+
onClear?: () => void;
|
|
18
|
+
/** Subscribes to what the host holds after any page picks or clears; returns an unsubscribe. */
|
|
19
|
+
watchSummary?: (listener: (summary: string | null) => void) => () => void;
|
|
20
|
+
/** Shown when the pick fails; the host's own message. */
|
|
21
|
+
onError?: (message: string) => void;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let {
|
|
26
|
+
inputId,
|
|
27
|
+
kinds,
|
|
28
|
+
atMost,
|
|
29
|
+
summary,
|
|
30
|
+
onPick,
|
|
31
|
+
onClear,
|
|
32
|
+
watchSummary,
|
|
33
|
+
onError,
|
|
34
|
+
disabled = false
|
|
35
|
+
}: Props = $props();
|
|
36
|
+
|
|
37
|
+
const locale = getLocaleContext();
|
|
38
|
+
const t = $derived(locale.messages);
|
|
39
|
+
|
|
40
|
+
type PickLabels = readonly [one: keyof ViewerMessages, other: keyof ViewerMessages];
|
|
41
|
+
|
|
42
|
+
const ANY_LABELS: PickLabels = ['geometryPickAnyOne', 'geometryPickAnyOther'];
|
|
43
|
+
const KIND_LABELS: Record<PickKind, PickLabels> = {
|
|
44
|
+
point: ['geometryPickPointOne', 'geometryPickPointOther'],
|
|
45
|
+
pointCloud: ['geometryPickPointCloudOne', 'geometryPickPointCloudOther'],
|
|
46
|
+
curve: ['geometryPickCurveOne', 'geometryPickCurveOther'],
|
|
47
|
+
line: ['geometryPickLineOne', 'geometryPickLineOther'],
|
|
48
|
+
plane: ['geometryPickPlaneOne', 'geometryPickPlaneOther'],
|
|
49
|
+
surface: ['geometryPickSurfaceOne', 'geometryPickSurfaceOther'],
|
|
50
|
+
polysurface: ['geometryPickPolysurfaceOne', 'geometryPickPolysurfaceOther'],
|
|
51
|
+
extrusion: ['geometryPickExtrusionOne', 'geometryPickExtrusionOther'],
|
|
52
|
+
mesh: ['geometryPickMeshOne', 'geometryPickMeshOther'],
|
|
53
|
+
subd: ['geometryPickSubdOne', 'geometryPickSubdOther'],
|
|
54
|
+
block: ['geometryPickBlockOne', 'geometryPickBlockOther']
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const pickLabel = $derived.by(() => {
|
|
58
|
+
const [one, other] = kinds?.length === 1 ? KIND_LABELS[kinds[0]] : ANY_LABELS;
|
|
59
|
+
return t[atMost === 1 ? one : other];
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Rhino's command line prompts during the pick; the page keeps the button from being pressed
|
|
63
|
+
// twice and says where to look.
|
|
64
|
+
let picking = $state(false);
|
|
65
|
+
// The last pick or clear wins over what the host reported when the page loaded.
|
|
66
|
+
let local = $state<string | null | undefined>(undefined);
|
|
67
|
+
const shown = $derived(local !== undefined ? local : (summary ?? null));
|
|
68
|
+
|
|
69
|
+
$effect(() =>
|
|
70
|
+
watchSummary?.((held) => {
|
|
71
|
+
local = held;
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
async function pick(onPick: () => Promise<GeometryPickResult>) {
|
|
76
|
+
picking = true;
|
|
77
|
+
try {
|
|
78
|
+
const result = await onPick();
|
|
79
|
+
if (result.outcome === 'picked') local = result.summary;
|
|
80
|
+
else if (result.outcome === 'failed') onError?.(result.error);
|
|
81
|
+
} finally {
|
|
82
|
+
picking = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The field label points at inputId: the button where there is one, else an <output>, which
|
|
87
|
+
// unlike a <span> can be labelled.
|
|
88
|
+
const readout = $derived(onPick ? 'span' : 'output');
|
|
89
|
+
|
|
90
|
+
function clear(onClear: () => void) {
|
|
91
|
+
local = null;
|
|
92
|
+
onClear();
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<div class="gap-2 min-w-0 flex flex-wrap items-center">
|
|
97
|
+
{#if onPick}
|
|
98
|
+
<Button
|
|
99
|
+
id={inputId}
|
|
100
|
+
variant="outline"
|
|
101
|
+
size="sm"
|
|
102
|
+
onclick={() => pick(onPick)}
|
|
103
|
+
disabled={disabled || picking}
|
|
104
|
+
>
|
|
105
|
+
{shown ? t.geometryPickAgain : pickLabel}
|
|
106
|
+
</Button>
|
|
107
|
+
{/if}
|
|
108
|
+
{#if picking}
|
|
109
|
+
<span class="text-sm min-w-0 text-muted-foreground" role="status">{t.geometryPicking}</span>
|
|
110
|
+
{:else if shown}
|
|
111
|
+
<svelte:element
|
|
112
|
+
this={readout}
|
|
113
|
+
id={onPick ? undefined : inputId}
|
|
114
|
+
class="text-sm min-w-0 truncate text-foreground"
|
|
115
|
+
>
|
|
116
|
+
{shown}
|
|
117
|
+
</svelte:element>
|
|
118
|
+
{#if onClear}
|
|
119
|
+
<Button variant="ghost" size="sm" onclick={() => clear(onClear)} {disabled}>
|
|
120
|
+
{t.geometryClear}
|
|
121
|
+
</Button>
|
|
122
|
+
{/if}
|
|
123
|
+
{:else if !onPick}
|
|
124
|
+
<output id={inputId} class="text-sm min-w-0 text-muted-foreground"
|
|
125
|
+
>{t.geometryPickInPanel}</output
|
|
126
|
+
>
|
|
127
|
+
{/if}
|
|
128
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { GlyphSource } from '$lib/schema/option-display';
|
|
3
|
+
import { cn } from '$lib/utils.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
glyph: GlyphSource;
|
|
7
|
+
class?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { glyph, class: className }: Props = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
{#if glyph.kind === 'icon'}
|
|
14
|
+
<!-- Lucide's stroke defaults; the path is bound as an attribute, never parsed as markup. -->
|
|
15
|
+
<svg
|
|
16
|
+
viewBox="0 0 24 24"
|
|
17
|
+
fill="none"
|
|
18
|
+
stroke="currentColor"
|
|
19
|
+
stroke-width="2"
|
|
20
|
+
stroke-linecap="round"
|
|
21
|
+
stroke-linejoin="round"
|
|
22
|
+
aria-hidden="true"
|
|
23
|
+
class={cn('size-4 shrink-0', className)}
|
|
24
|
+
>
|
|
25
|
+
<path d={glyph.d} />
|
|
26
|
+
</svg>
|
|
27
|
+
{:else}
|
|
28
|
+
<img src={glyph.src} alt="" class={cn('size-4 shrink-0 rounded-sm object-cover', className)} />
|
|
29
|
+
{/if}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
|
|
3
|
+
import * as ToggleGroup from '$lib/components/primitives/toggle-group';
|
|
4
|
+
import { optionGlyph } from '$lib/schema/option-display';
|
|
5
|
+
import OptionGlyph from './OptionGlyph.svelte';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
labelId: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
config: DropdownWidgetConfig;
|
|
11
|
+
assets?: UISchema['assets'];
|
|
12
|
+
onChange: (value: SupportedTypes) => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { labelId, value, config, assets, onChange, disabled = false }: Props = $props();
|
|
17
|
+
|
|
18
|
+
const options = $derived(Object.entries(config.options ?? {}));
|
|
19
|
+
const hasIcons = $derived(
|
|
20
|
+
options.some(([name]) => optionGlyph(config.optionMedia?.[name], assets))
|
|
21
|
+
);
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<ToggleGroup.Root
|
|
25
|
+
type="single"
|
|
26
|
+
variant="outline"
|
|
27
|
+
size={hasIcons ? 'stacked' : 'default'}
|
|
28
|
+
class="w-full"
|
|
29
|
+
aria-labelledby={labelId}
|
|
30
|
+
value={value ?? ''}
|
|
31
|
+
onValueChange={(next) => {
|
|
32
|
+
// A single-select group emits '' when the active item is clicked again; keep the selection.
|
|
33
|
+
if (next) onChange(next);
|
|
34
|
+
}}
|
|
35
|
+
{disabled}
|
|
36
|
+
>
|
|
37
|
+
{#each options as [name, expr] (name)}
|
|
38
|
+
{@const glyph = optionGlyph(config.optionMedia?.[name], assets)}
|
|
39
|
+
<ToggleGroup.Item value={expr ?? name} class="min-w-0 flex-1" title={name}>
|
|
40
|
+
{#if glyph}<OptionGlyph {glyph} />{/if}
|
|
41
|
+
<span class="max-w-full truncate">{name}</span>
|
|
42
|
+
</ToggleGroup.Item>
|
|
43
|
+
{/each}
|
|
44
|
+
</ToggleGroup.Root>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
|
|
3
|
+
import { optionGlyph } from '$lib/schema/option-display';
|
|
4
|
+
import OptionGlyph from './OptionGlyph.svelte';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
labelId: string;
|
|
8
|
+
value?: string;
|
|
9
|
+
config: DropdownWidgetConfig;
|
|
10
|
+
assets?: UISchema['assets'];
|
|
11
|
+
onChange: (value: SupportedTypes) => void;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { labelId, value, config, assets, onChange, disabled = false }: Props = $props();
|
|
16
|
+
|
|
17
|
+
const options = $derived(
|
|
18
|
+
Object.entries(config.options ?? {}).map(([name, expr]) => ({ name, value: expr ?? name }))
|
|
19
|
+
);
|
|
20
|
+
// With nothing selected the first tile takes focus, so the group is still reachable by Tab.
|
|
21
|
+
const focusIndex = $derived(
|
|
22
|
+
Math.max(
|
|
23
|
+
0,
|
|
24
|
+
options.findIndex((o) => o.value === value)
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
let tiles: HTMLButtonElement[] = $state([]);
|
|
29
|
+
|
|
30
|
+
function onKeydown(event: KeyboardEvent, index: number) {
|
|
31
|
+
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
|
32
|
+
if (!step) return;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
const next = (index + step + options.length) % options.length;
|
|
35
|
+
onChange(options[next].value);
|
|
36
|
+
tiles[next]?.focus();
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<div role="radiogroup" aria-labelledby={labelId} class="gap-2 tile-grid grid">
|
|
41
|
+
{#each options as option, index (option.name)}
|
|
42
|
+
{@const glyph = optionGlyph(config.optionMedia?.[option.name], assets)}
|
|
43
|
+
{@const selected = option.value === value}
|
|
44
|
+
<button
|
|
45
|
+
bind:this={tiles[index]}
|
|
46
|
+
type="button"
|
|
47
|
+
role="radio"
|
|
48
|
+
aria-checked={selected}
|
|
49
|
+
tabindex={index === focusIndex ? 0 : -1}
|
|
50
|
+
{disabled}
|
|
51
|
+
onclick={() => onChange(option.value)}
|
|
52
|
+
onkeydown={(e) => onKeydown(e, index)}
|
|
53
|
+
class="gap-1.5 p-1.5 min-w-0 flex flex-col rounded-lg border text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 {selected
|
|
54
|
+
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
|
55
|
+
: 'border-input hover:bg-accent/40'}"
|
|
56
|
+
>
|
|
57
|
+
<span
|
|
58
|
+
class="flex aspect-square w-full items-center justify-center overflow-hidden rounded-md bg-muted text-muted-foreground"
|
|
59
|
+
>
|
|
60
|
+
{#if glyph?.kind === 'image'}
|
|
61
|
+
<OptionGlyph {glyph} class="size-full" />
|
|
62
|
+
{:else if glyph}
|
|
63
|
+
<OptionGlyph {glyph} class="size-7" />
|
|
64
|
+
{:else}
|
|
65
|
+
<span class="text-lg font-medium" aria-hidden="true">{option.name.charAt(0)}</span>
|
|
66
|
+
{/if}
|
|
67
|
+
</span>
|
|
68
|
+
<span class="px-0.5 text-xs leading-tight truncate" class:font-medium={selected}>
|
|
69
|
+
{option.name}
|
|
70
|
+
</span>
|
|
71
|
+
</button>
|
|
72
|
+
{/each}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
/* Two or three tiles a row as the panel narrows; a fixed count shrinks them to unreadable. */
|
|
77
|
+
.tile-grid {
|
|
78
|
+
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -3,5 +3,8 @@ export { default as ChecklistInput } from './ChecklistInput.svelte';
|
|
|
3
3
|
export { default as ColorInput } from './ColorInput.svelte';
|
|
4
4
|
export { default as DropdownInput } from './DropdownInput.svelte';
|
|
5
5
|
export { default as FileInput } from './FileInput.svelte';
|
|
6
|
+
export { default as GeometryInput } from './GeometryInput.svelte';
|
|
6
7
|
export { default as NumberInput } from './NumberInput.svelte';
|
|
8
|
+
export { default as SegmentedInput } from './SegmentedInput.svelte';
|
|
7
9
|
export { default as TextInput } from './TextInput.svelte';
|
|
10
|
+
export { default as TileInput } from './TileInput.svelte';
|
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
show: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* `floating` overlays the bottom-left corner. `inline` is a compact chip the size of an
|
|
8
|
+
* icon button, for a narrow host whose corner holds its content and whose action row
|
|
9
|
+
* already reserves the chip's place. That host freezes while it solves, so the chip says
|
|
10
|
+
* Esc aborts: a frozen page can't offer a button.
|
|
11
|
+
*/
|
|
12
|
+
placement?: 'floating' | 'inline';
|
|
6
13
|
}
|
|
7
14
|
|
|
8
|
-
let { show }: Props = $props();
|
|
15
|
+
let { show, placement = 'floating' }: Props = $props();
|
|
9
16
|
|
|
10
17
|
const locale = getLocaleContext();
|
|
11
18
|
const t = $derived(locale.messages);
|
|
@@ -13,8 +20,26 @@
|
|
|
13
20
|
|
|
14
21
|
<!-- The live region stays mounted: a screen reader only announces text that changes inside a
|
|
15
22
|
region it already knows about, so one created together with its text says nothing. -->
|
|
16
|
-
<div
|
|
17
|
-
|
|
23
|
+
<div
|
|
24
|
+
role="status"
|
|
25
|
+
aria-live="polite"
|
|
26
|
+
aria-atomic="true"
|
|
27
|
+
class={placement === 'inline' ? 'h-10 gap-3 min-w-0 flex items-center' : undefined}
|
|
28
|
+
>
|
|
29
|
+
{#if show && placement === 'inline'}
|
|
30
|
+
<div
|
|
31
|
+
class="gap-2 px-3 h-10 text-sm font-semibold flex shrink-0 items-center rounded-md bg-primary text-primary-foreground"
|
|
32
|
+
>
|
|
33
|
+
<div
|
|
34
|
+
class="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
></div>
|
|
37
|
+
{t.solvingEllipsis}
|
|
38
|
+
</div>
|
|
39
|
+
<span class="text-xs min-w-0 truncate text-muted-foreground" data-testid="solve-esc-hint">
|
|
40
|
+
{t.solveMessagesEscHint}
|
|
41
|
+
</span>
|
|
42
|
+
{:else if show}
|
|
18
43
|
<div
|
|
19
44
|
class="bottom-6 left-6 gap-3 px-5 py-3 shadow-xl animate-in fade-in slide-in-from-left fixed z-50 flex items-center rounded-lg border border-primary/20 bg-primary text-primary-foreground duration-200"
|
|
20
45
|
>
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
'bg-destructive shadow-xs hover:bg-destructive/90 dark:bg-destructive/60 text-destructive-foreground',
|
|
13
13
|
outline:
|
|
14
14
|
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
|
15
|
+
// Outline with an opaque dark fill: the translucent one washes out over the light 3D canvas.
|
|
16
|
+
overlay:
|
|
17
|
+
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input border',
|
|
15
18
|
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
|
16
19
|
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
|
17
20
|
dashed:
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
default:
|
|
13
13
|
'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
14
14
|
sm: "h-7 min-w-7 rounded-md px-2.5 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
15
|
-
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2'
|
|
15
|
+
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
16
|
+
// Icon over a short label, so a row of options still fits a phone-width column.
|
|
17
|
+
stacked: 'min-h-12 min-w-0 flex-col gap-1 px-1 py-1.5 text-xs'
|
|
16
18
|
}
|
|
17
19
|
},
|
|
18
20
|
defaultVariants: {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { Button } from '$lib/components/primitives/button/index.js';
|
|
41
41
|
import type { Locale } from '$lib/i18n/messages';
|
|
42
42
|
import { layerLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
43
|
+
import { saveBlob } from '$lib/utils/host-files';
|
|
43
44
|
|
|
44
45
|
export interface ViewerConfig {
|
|
45
46
|
showScreenshotButton?: boolean;
|
|
@@ -433,15 +434,8 @@
|
|
|
433
434
|
const blob = await captureImage?.();
|
|
434
435
|
if (!blob) return;
|
|
435
436
|
|
|
436
|
-
const url = URL.createObjectURL(blob);
|
|
437
|
-
const link = document.createElement('a');
|
|
438
437
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
|
439
|
-
|
|
440
|
-
link.download = `viewer-${timestamp}.png`;
|
|
441
|
-
document.body.appendChild(link);
|
|
442
|
-
link.click();
|
|
443
|
-
document.body.removeChild(link);
|
|
444
|
-
URL.revokeObjectURL(url);
|
|
438
|
+
await saveBlob(blob, `viewer-${timestamp}.png`);
|
|
445
439
|
}
|
|
446
440
|
</script>
|
|
447
441
|
|
|
@@ -462,7 +456,7 @@
|
|
|
462
456
|
the DOM, so this is the only observable proof a solve's geometry decoded and
|
|
463
457
|
rendered; e2e asserts on it. -->
|
|
464
458
|
<canvas
|
|
465
|
-
class="block h-full w-full"
|
|
459
|
+
class="viewer-canvas block h-full w-full select-none"
|
|
466
460
|
data-testid="viewer-canvas"
|
|
467
461
|
data-mesh-count={meshes.length}
|
|
468
462
|
bind:this={canvas}
|
|
@@ -513,7 +507,7 @@
|
|
|
513
507
|
{#snippet child({ props })}
|
|
514
508
|
<Button
|
|
515
509
|
{...props}
|
|
516
|
-
variant="
|
|
510
|
+
variant="overlay"
|
|
517
511
|
size="icon-lg"
|
|
518
512
|
title={t.toolsMenu}
|
|
519
513
|
aria-label={t.toolsMenu}
|
|
@@ -630,7 +624,7 @@
|
|
|
630
624
|
<!-- Touch has no F key, and fit-to-view is the way back from a framed part. -->
|
|
631
625
|
<div class="hidden pointer-coarse:block">
|
|
632
626
|
<Button
|
|
633
|
-
variant="
|
|
627
|
+
variant="overlay"
|
|
634
628
|
size="icon-lg"
|
|
635
629
|
title={t.fitToView}
|
|
636
630
|
aria-label={t.fitToView}
|
|
@@ -666,6 +660,12 @@
|
|
|
666
660
|
border-radius: 0 !important;
|
|
667
661
|
}
|
|
668
662
|
|
|
663
|
+
/* A long press on touch otherwise selects the canvas as an image and opens the save/copy callout. */
|
|
664
|
+
.viewer-canvas {
|
|
665
|
+
-webkit-touch-callout: none;
|
|
666
|
+
-webkit-tap-highlight-color: transparent;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
669
|
.blur-overlay {
|
|
670
670
|
pointer-events: none;
|
|
671
671
|
}
|
|
@@ -9,8 +9,23 @@ import { APP_DEFAULTS } from '../constants';
|
|
|
9
9
|
* - Average over SLOW_THRESHOLD_MS: shown immediately
|
|
10
10
|
* - Between the two: shown after a proportional slice of ANIMATION_DELAY
|
|
11
11
|
* - First solve: shown immediately, there is no history yet
|
|
12
|
+
*
|
|
13
|
+
* `freezesDuringSolve` is for a host that can't paint while it solves (an embedded WebView on
|
|
14
|
+
* the solver's thread). There the indicator has to be on screen before the solve starts, when its
|
|
15
|
+
* length is still unknown, so it follows `isSolving` exactly: no history, no delay.
|
|
12
16
|
*/
|
|
13
|
-
export function createSolvingIndicator(
|
|
17
|
+
export function createSolvingIndicator(
|
|
18
|
+
isSolving: () => boolean,
|
|
19
|
+
options: { freezesDuringSolve?: boolean } = {}
|
|
20
|
+
): { readonly show: boolean } {
|
|
21
|
+
if (options.freezesDuringSolve) {
|
|
22
|
+
return {
|
|
23
|
+
get show() {
|
|
24
|
+
return isSolving();
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
const { FAST_THRESHOLD_MS, SLOW_THRESHOLD_MS, HISTORY_SIZE, ANIMATION_DELAY } =
|
|
15
30
|
APP_DEFAULTS.SOLVING_INDICATOR;
|
|
16
31
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import type { GeometryKind } from '@selvajs/schemas';
|
|
3
|
+
import type { UIHost } from '$lib/schema/host-support';
|
|
4
|
+
|
|
5
|
+
// Tells InputControl which host it renders in and how to reach the host's own actions, without
|
|
6
|
+
// threading a prop through every layout layer. Without a provider the host is Selva.
|
|
7
|
+
|
|
8
|
+
export type GeometryPickResult =
|
|
9
|
+
| { outcome: 'picked'; summary: string }
|
|
10
|
+
| { outcome: 'cancelled' }
|
|
11
|
+
| { outcome: 'failed'; error: string };
|
|
12
|
+
|
|
13
|
+
/** What a pick button can ask for: a Get Geometry filter's kinds, or Get Point/Line/Plane's own. */
|
|
14
|
+
export type PickKind = GeometryKind | 'line' | 'plane';
|
|
15
|
+
|
|
16
|
+
/** What a geometry input lets the user pick, as the host discovered it on the canvas. */
|
|
17
|
+
export interface GeometryPickTarget {
|
|
18
|
+
/** The kinds its filter allows; absent when it takes anything. */
|
|
19
|
+
kinds?: readonly GeometryKind[];
|
|
20
|
+
/** 1 picks a single object. */
|
|
21
|
+
atMost?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HostActions {
|
|
25
|
+
host: UIHost;
|
|
26
|
+
/** Prompts in the Rhino viewport for a geometry input. The picked geometry stays in Grasshopper. */
|
|
27
|
+
pickGeometry?: (paramId: string) => Promise<GeometryPickResult>;
|
|
28
|
+
/** Empties a geometry input again, back to the definition's own value. */
|
|
29
|
+
clearGeometry?: (paramId: string) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Reports what the param holds after every pick or clear, including those another page asked
|
|
32
|
+
* for; null when nothing is picked into it. Returns an unsubscribe.
|
|
33
|
+
*/
|
|
34
|
+
onGeometrySummary?: (paramId: string, listener: (summary: string | null) => void) => () => void;
|
|
35
|
+
describeGeometry?: (paramId: string) => GeometryPickTarget | undefined;
|
|
36
|
+
/** Presses a button input: one solve with `true`, then it goes back to `false`. */
|
|
37
|
+
trigger?: (paramId: string) => void;
|
|
38
|
+
/** Grasshopper refuses a press while it solves, so the button waits it out disabled. */
|
|
39
|
+
isSolving?: () => boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SELVA_HOST: HostActions = { host: 'selva' };
|
|
43
|
+
const HOST_CONTEXT_KEY = Symbol('host-context');
|
|
44
|
+
|
|
45
|
+
export function setHostContext(actions: HostActions): void {
|
|
46
|
+
setContext(HOST_CONTEXT_KEY, actions);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getHostContext(): HostActions {
|
|
50
|
+
return getContext<HostActions | undefined>(HOST_CONTEXT_KEY) ?? SELVA_HOST;
|
|
51
|
+
}
|
package/src/lib/i18n/messages.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface ViewerMessages {
|
|
|
73
73
|
solveMessagesConfirm: string;
|
|
74
74
|
solveMessagesDiscard: string;
|
|
75
75
|
solveMessagesAbort: string;
|
|
76
|
+
/** Stands in for Abort where the page can't take a click mid-solve. */
|
|
77
|
+
solveMessagesEscHint: string;
|
|
76
78
|
solveMessagesDismiss: string;
|
|
77
79
|
solveSolving: string;
|
|
78
80
|
parametersTab: string;
|
|
@@ -131,6 +133,40 @@ export interface ViewerMessages {
|
|
|
131
133
|
invalidFormat: string;
|
|
132
134
|
invalidPattern: string;
|
|
133
135
|
|
|
136
|
+
// Geometry input (Rhino only)
|
|
137
|
+
/** The pick button when the input takes several kinds of object. */
|
|
138
|
+
geometryPickAnyOne: string;
|
|
139
|
+
geometryPickAnyOther: string;
|
|
140
|
+
/** Shown while Rhino's viewport prompt for a pick is open. */
|
|
141
|
+
geometryPicking: string;
|
|
142
|
+
/** Where this page may not pick, such as a browser tab beside the Rhino panel. */
|
|
143
|
+
geometryPickInPanel: string;
|
|
144
|
+
geometryPickAgain: string;
|
|
145
|
+
geometryClear: string;
|
|
146
|
+
// The pick button when the input takes one kind: `One` picks a single object, `Other` several.
|
|
147
|
+
geometryPickPointOne: string;
|
|
148
|
+
geometryPickPointOther: string;
|
|
149
|
+
geometryPickPointCloudOne: string;
|
|
150
|
+
geometryPickPointCloudOther: string;
|
|
151
|
+
geometryPickCurveOne: string;
|
|
152
|
+
geometryPickCurveOther: string;
|
|
153
|
+
geometryPickLineOne: string;
|
|
154
|
+
geometryPickLineOther: string;
|
|
155
|
+
geometryPickPlaneOne: string;
|
|
156
|
+
geometryPickPlaneOther: string;
|
|
157
|
+
geometryPickSurfaceOne: string;
|
|
158
|
+
geometryPickSurfaceOther: string;
|
|
159
|
+
geometryPickPolysurfaceOne: string;
|
|
160
|
+
geometryPickPolysurfaceOther: string;
|
|
161
|
+
geometryPickExtrusionOne: string;
|
|
162
|
+
geometryPickExtrusionOther: string;
|
|
163
|
+
geometryPickMeshOne: string;
|
|
164
|
+
geometryPickMeshOther: string;
|
|
165
|
+
geometryPickSubdOne: string;
|
|
166
|
+
geometryPickSubdOther: string;
|
|
167
|
+
geometryPickBlockOne: string;
|
|
168
|
+
geometryPickBlockOther: string;
|
|
169
|
+
|
|
134
170
|
// File input
|
|
135
171
|
fileModeUpload: string;
|
|
136
172
|
fileModeUrl: string;
|
|
@@ -261,6 +297,7 @@ const en: ViewerMessages = {
|
|
|
261
297
|
solveMessagesConfirm: 'Continue',
|
|
262
298
|
solveMessagesDiscard: 'Discard',
|
|
263
299
|
solveMessagesAbort: 'Abort',
|
|
300
|
+
solveMessagesEscHint: 'Esc aborts the solve',
|
|
264
301
|
solveMessagesDismiss: 'Dismiss',
|
|
265
302
|
solveSolving: 'Solving…',
|
|
266
303
|
parametersTab: 'Parameters',
|
|
@@ -305,6 +342,35 @@ const en: ViewerMessages = {
|
|
|
305
342
|
invalidFormat: 'Invalid format',
|
|
306
343
|
invalidPattern: 'Invalid validation pattern configured',
|
|
307
344
|
|
|
345
|
+
geometryPickAnyOne: 'Pick an object',
|
|
346
|
+
geometryPickAnyOther: 'Pick objects',
|
|
347
|
+
geometryPicking: 'Pick in the viewport, Esc to cancel',
|
|
348
|
+
geometryPickInPanel: 'Pick in the Rhino panel',
|
|
349
|
+
geometryPickAgain: 'Pick again',
|
|
350
|
+
geometryClear: 'Clear',
|
|
351
|
+
geometryPickPointOne: 'Pick a point',
|
|
352
|
+
geometryPickPointOther: 'Pick points',
|
|
353
|
+
geometryPickPointCloudOne: 'Pick a point cloud',
|
|
354
|
+
geometryPickPointCloudOther: 'Pick point clouds',
|
|
355
|
+
geometryPickCurveOne: 'Pick a curve',
|
|
356
|
+
geometryPickCurveOther: 'Pick curves',
|
|
357
|
+
geometryPickLineOne: 'Pick a line',
|
|
358
|
+
geometryPickLineOther: 'Pick lines',
|
|
359
|
+
geometryPickPlaneOne: 'Pick a plane',
|
|
360
|
+
geometryPickPlaneOther: 'Pick planes',
|
|
361
|
+
geometryPickSurfaceOne: 'Pick a surface',
|
|
362
|
+
geometryPickSurfaceOther: 'Pick surfaces',
|
|
363
|
+
geometryPickPolysurfaceOne: 'Pick a polysurface',
|
|
364
|
+
geometryPickPolysurfaceOther: 'Pick polysurfaces',
|
|
365
|
+
geometryPickExtrusionOne: 'Pick an extrusion',
|
|
366
|
+
geometryPickExtrusionOther: 'Pick extrusions',
|
|
367
|
+
geometryPickMeshOne: 'Pick a mesh',
|
|
368
|
+
geometryPickMeshOther: 'Pick meshes',
|
|
369
|
+
geometryPickSubdOne: 'Pick a SubD',
|
|
370
|
+
geometryPickSubdOther: 'Pick SubDs',
|
|
371
|
+
geometryPickBlockOne: 'Pick a block',
|
|
372
|
+
geometryPickBlockOther: 'Pick blocks',
|
|
373
|
+
|
|
308
374
|
fileModeUpload: 'Upload',
|
|
309
375
|
fileModeUrl: 'URL',
|
|
310
376
|
fileUrlLabel: 'File URL',
|
|
@@ -425,6 +491,7 @@ const de: ViewerMessages = {
|
|
|
425
491
|
solveMessagesConfirm: 'Weiter',
|
|
426
492
|
solveMessagesDiscard: 'Verwerfen',
|
|
427
493
|
solveMessagesAbort: 'Abbrechen',
|
|
494
|
+
solveMessagesEscHint: 'Esc bricht die Berechnung ab',
|
|
428
495
|
solveMessagesDismiss: 'Schliessen',
|
|
429
496
|
solveSolving: 'Berechnung läuft…',
|
|
430
497
|
parametersTab: 'Parameter',
|
|
@@ -469,6 +536,35 @@ const de: ViewerMessages = {
|
|
|
469
536
|
invalidFormat: 'Ungültiges Format',
|
|
470
537
|
invalidPattern: 'Ungültiges Prüfmuster hinterlegt',
|
|
471
538
|
|
|
539
|
+
geometryPickAnyOne: 'Objekt wählen',
|
|
540
|
+
geometryPickAnyOther: 'Objekte wählen',
|
|
541
|
+
geometryPicking: 'Im Ansichtsfenster wählen, Esc bricht ab',
|
|
542
|
+
geometryPickInPanel: 'Im Rhino-Panel wählen',
|
|
543
|
+
geometryPickAgain: 'Neu wählen',
|
|
544
|
+
geometryClear: 'Leeren',
|
|
545
|
+
geometryPickPointOne: 'Punkt wählen',
|
|
546
|
+
geometryPickPointOther: 'Punkte wählen',
|
|
547
|
+
geometryPickPointCloudOne: 'Punktwolke wählen',
|
|
548
|
+
geometryPickPointCloudOther: 'Punktwolken wählen',
|
|
549
|
+
geometryPickCurveOne: 'Kurve wählen',
|
|
550
|
+
geometryPickCurveOther: 'Kurven wählen',
|
|
551
|
+
geometryPickLineOne: 'Linie wählen',
|
|
552
|
+
geometryPickLineOther: 'Linien wählen',
|
|
553
|
+
geometryPickPlaneOne: 'Ebene wählen',
|
|
554
|
+
geometryPickPlaneOther: 'Ebenen wählen',
|
|
555
|
+
geometryPickSurfaceOne: 'Fläche wählen',
|
|
556
|
+
geometryPickSurfaceOther: 'Flächen wählen',
|
|
557
|
+
geometryPickPolysurfaceOne: 'Flächenverband wählen',
|
|
558
|
+
geometryPickPolysurfaceOther: 'Flächenverbände wählen',
|
|
559
|
+
geometryPickExtrusionOne: 'Extrusion wählen',
|
|
560
|
+
geometryPickExtrusionOther: 'Extrusionen wählen',
|
|
561
|
+
geometryPickMeshOne: 'Netz wählen',
|
|
562
|
+
geometryPickMeshOther: 'Netze wählen',
|
|
563
|
+
geometryPickSubdOne: 'SubD wählen',
|
|
564
|
+
geometryPickSubdOther: 'SubDs wählen',
|
|
565
|
+
geometryPickBlockOne: 'Block wählen',
|
|
566
|
+
geometryPickBlockOther: 'Blöcke wählen',
|
|
567
|
+
|
|
472
568
|
fileModeUpload: 'Hochladen',
|
|
473
569
|
fileModeUrl: 'URL',
|
|
474
570
|
fileUrlLabel: 'Datei-URL',
|
package/src/lib/index.ts
CHANGED
|
@@ -19,6 +19,17 @@ export { default as Viewer } from './components/viewer/Viewer.svelte';
|
|
|
19
19
|
export * from './schema/defaults';
|
|
20
20
|
export * from './schema/dynamic-value-list';
|
|
21
21
|
export * from './schema/traversal';
|
|
22
|
+
export {
|
|
23
|
+
type UIHost,
|
|
24
|
+
type ElementKind,
|
|
25
|
+
HOST_LABELS,
|
|
26
|
+
isGeometryParamType,
|
|
27
|
+
layoutItemKind,
|
|
28
|
+
hostsFor,
|
|
29
|
+
isSupportedOn,
|
|
30
|
+
valuelessInputIds,
|
|
31
|
+
unsupportedItems
|
|
32
|
+
} from './schema/host-support';
|
|
22
33
|
export * from './compute/solving.svelte';
|
|
23
34
|
|
|
24
35
|
// Solve Session seam. Re-exported so transports outside this package — e.g.
|
|
@@ -54,10 +65,19 @@ export {
|
|
|
54
65
|
|
|
55
66
|
export * from './contexts/footerContext.svelte';
|
|
56
67
|
export * from './contexts/clientSlotContext.svelte';
|
|
68
|
+
export * from './contexts/hostContext.svelte';
|
|
57
69
|
export * from './composables/useFooterItem.svelte';
|
|
58
70
|
|
|
59
71
|
export * from './utils';
|
|
60
72
|
export { randomId } from './utils/randomId';
|
|
73
|
+
export {
|
|
74
|
+
saveBlob,
|
|
75
|
+
pickFile,
|
|
76
|
+
setBlobSaver,
|
|
77
|
+
setFilePicker,
|
|
78
|
+
type BlobSaver,
|
|
79
|
+
type FilePicker
|
|
80
|
+
} from './utils/host-files';
|
|
61
81
|
|
|
62
82
|
// UI-specific runtime types (not from schema)
|
|
63
83
|
export type { ActionButton } from './types/actionButton';
|