@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.
Files changed (93) hide show
  1. package/dist/components/compute/AppLayout.svelte +37 -7
  2. package/dist/components/compute/AppLayout.svelte.d.ts +5 -0
  3. package/dist/components/compute/ParameterPresetManager.svelte +8 -20
  4. package/dist/components/compute/SolveMessages.svelte +26 -3
  5. package/dist/components/compute/SolveMessages.svelte.d.ts +7 -0
  6. package/dist/components/layout/PageFooter.svelte +3 -1
  7. package/dist/components/layout/SideNav.svelte +55 -14
  8. package/dist/components/preview/ChartOutput.svelte +18 -13
  9. package/dist/components/preview/Group.svelte +6 -1
  10. package/dist/components/preview/ImageOutput.svelte +3 -1
  11. package/dist/components/preview/InputControl.svelte +108 -44
  12. package/dist/components/preview/InputControl.svelte.d.ts +5 -1
  13. package/dist/components/preview/TabContent.svelte +61 -29
  14. package/dist/components/preview/TabContent.svelte.d.ts +5 -1
  15. package/dist/components/preview/TabLayout.svelte +25 -3
  16. package/dist/components/preview/TabLayout.svelte.d.ts +5 -0
  17. package/dist/components/preview/inputs/ChecklistInput.svelte +7 -2
  18. package/dist/components/preview/inputs/ChecklistInput.svelte.d.ts +2 -1
  19. package/dist/components/preview/inputs/DropdownInput.svelte +16 -4
  20. package/dist/components/preview/inputs/DropdownInput.svelte.d.ts +2 -1
  21. package/dist/components/preview/inputs/FileInput.svelte +56 -65
  22. package/dist/components/preview/inputs/GeometryInput.svelte +128 -0
  23. package/dist/components/preview/inputs/GeometryInput.svelte.d.ts +21 -0
  24. package/dist/components/preview/inputs/OptionGlyph.svelte +29 -0
  25. package/dist/components/preview/inputs/OptionGlyph.svelte.d.ts +8 -0
  26. package/dist/components/preview/inputs/SegmentedInput.svelte +44 -0
  27. package/dist/components/preview/inputs/SegmentedInput.svelte.d.ts +12 -0
  28. package/dist/components/preview/inputs/TileInput.svelte +80 -0
  29. package/dist/components/preview/inputs/TileInput.svelte.d.ts +12 -0
  30. package/dist/components/preview/inputs/index.d.ts +3 -0
  31. package/dist/components/preview/inputs/index.js +3 -0
  32. package/dist/components/primitives/SolvingIndicator.svelte +28 -3
  33. package/dist/components/primitives/SolvingIndicator.svelte.d.ts +7 -0
  34. package/dist/components/primitives/button/button.svelte +3 -0
  35. package/dist/components/primitives/button/button.svelte.d.ts +3 -0
  36. package/dist/components/primitives/toggle/toggle.svelte +3 -1
  37. package/dist/components/primitives/toggle/toggle.svelte.d.ts +3 -0
  38. package/dist/components/viewer/Viewer.svelte +11 -11
  39. package/dist/compute/solving.svelte.d.ts +7 -1
  40. package/dist/compute/solving.svelte.js +12 -1
  41. package/dist/contexts/hostContext.svelte.d.ts +39 -0
  42. package/dist/contexts/hostContext.svelte.js +9 -0
  43. package/dist/i18n/messages.d.ts +33 -0
  44. package/dist/i18n/messages.js +58 -0
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.js +3 -0
  47. package/dist/schema/host-support.d.ts +29 -0
  48. package/dist/schema/host-support.js +97 -0
  49. package/dist/schema/option-display.d.ts +12 -0
  50. package/dist/schema/option-display.js +29 -0
  51. package/dist/schema/param-exporter.d.ts +4 -1
  52. package/dist/schema/param-exporter.js +10 -6
  53. package/dist/styles/base.css +6 -3
  54. package/dist/utils/file-download.js +6 -10
  55. package/dist/utils/host-files.d.ts +12 -0
  56. package/dist/utils/host-files.js +46 -0
  57. package/dist/utils/webp-to-png.d.ts +10 -0
  58. package/dist/utils/webp-to-png.js +41 -0
  59. package/package.json +9 -9
  60. package/src/lib/components/compute/AppLayout.svelte +37 -7
  61. package/src/lib/components/compute/ParameterPresetManager.svelte +8 -20
  62. package/src/lib/components/compute/SolveMessages.svelte +26 -3
  63. package/src/lib/components/layout/PageFooter.svelte +3 -1
  64. package/src/lib/components/layout/SideNav.svelte +55 -14
  65. package/src/lib/components/preview/ChartOutput.svelte +18 -13
  66. package/src/lib/components/preview/Group.svelte +6 -1
  67. package/src/lib/components/preview/ImageOutput.svelte +3 -1
  68. package/src/lib/components/preview/InputControl.svelte +108 -44
  69. package/src/lib/components/preview/TabContent.svelte +61 -29
  70. package/src/lib/components/preview/TabLayout.svelte +25 -3
  71. package/src/lib/components/preview/inputs/ChecklistInput.svelte +7 -2
  72. package/src/lib/components/preview/inputs/DropdownInput.svelte +16 -4
  73. package/src/lib/components/preview/inputs/FileInput.svelte +56 -65
  74. package/src/lib/components/preview/inputs/GeometryInput.svelte +128 -0
  75. package/src/lib/components/preview/inputs/OptionGlyph.svelte +29 -0
  76. package/src/lib/components/preview/inputs/SegmentedInput.svelte +44 -0
  77. package/src/lib/components/preview/inputs/TileInput.svelte +80 -0
  78. package/src/lib/components/preview/inputs/index.ts +3 -0
  79. package/src/lib/components/primitives/SolvingIndicator.svelte +28 -3
  80. package/src/lib/components/primitives/button/button.svelte +3 -0
  81. package/src/lib/components/primitives/toggle/toggle.svelte +3 -1
  82. package/src/lib/components/viewer/Viewer.svelte +11 -11
  83. package/src/lib/compute/solving.svelte.ts +16 -1
  84. package/src/lib/contexts/hostContext.svelte.ts +51 -0
  85. package/src/lib/i18n/messages.ts +96 -0
  86. package/src/lib/index.ts +20 -0
  87. package/src/lib/schema/host-support.ts +129 -0
  88. package/src/lib/schema/option-display.ts +44 -0
  89. package/src/lib/schema/param-exporter.ts +10 -7
  90. package/src/lib/styles/base.css +6 -3
  91. package/src/lib/utils/file-download.ts +7 -11
  92. package/src/lib/utils/host-files.ts +61 -0
  93. 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 role="status" aria-live="polite" aria-atomic="true">
17
- {#if show}
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
- link.href = url;
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="outline"
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="outline"
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(isSolving: () => boolean): { readonly show: boolean } {
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
+ }
@@ -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';