@selvajs/ui 6.6.0-beta.7 → 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 +10 -22
- 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 +11 -21
- package/src/lib/utils/host-files.ts +61 -0
- package/src/lib/utils/webp-to-png.ts +48 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { downloadFileDataByRoot } from '@selvajs/compute/core';
|
|
1
|
+
import { decodeBase64ToBinary, downloadFileDataByRoot } from '@selvajs/compute/core';
|
|
2
2
|
import { SvelteMap } from 'svelte/reactivity';
|
|
3
3
|
import { APP_DEFAULTS } from '../constants';
|
|
4
|
+
import { saveBlob } from './host-files';
|
|
4
5
|
export const MIME_BY_EXT = {
|
|
5
6
|
'.png': 'image/png',
|
|
6
7
|
'.jpg': 'image/jpeg',
|
|
@@ -20,23 +21,10 @@ export const MIME_BY_EXT = {
|
|
|
20
21
|
function saveSingleFile(file) {
|
|
21
22
|
const ext = (file.fileType ?? '').toLowerCase();
|
|
22
23
|
const mime = MIME_BY_EXT[ext] ?? 'application/octet-stream';
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
for (let i = 0; i < binary.length; i++)
|
|
28
|
-
bytes[i] = binary.charCodeAt(i);
|
|
29
|
-
blob = new Blob([bytes], { type: mime });
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
blob = new Blob([file.data], { type: mime });
|
|
33
|
-
}
|
|
34
|
-
const fullName = `${file.fileName}${file.fileType ?? ''}`;
|
|
35
|
-
const a = document.createElement('a');
|
|
36
|
-
a.href = URL.createObjectURL(blob);
|
|
37
|
-
a.download = fullName;
|
|
38
|
-
a.click();
|
|
39
|
-
URL.revokeObjectURL(a.href);
|
|
24
|
+
const content = file.isBase64Encoded ? decodeBase64ToBinary(file.data) : file.data;
|
|
25
|
+
// decodeBase64ToBinary returns an ArrayBuffer-backed copy, never a SharedArrayBuffer view.
|
|
26
|
+
const blob = new Blob([content], { type: mime });
|
|
27
|
+
return saveBlob(blob, `${file.fileName}${file.fileType ?? ''}`);
|
|
40
28
|
}
|
|
41
29
|
export async function downloadFiles(fileData, fileName = 'grasshopper-output') {
|
|
42
30
|
try {
|
|
@@ -45,13 +33,13 @@ export async function downloadFiles(fileData, fileName = 'grasshopper-output') {
|
|
|
45
33
|
console.warn('[FileDownload] No files to download');
|
|
46
34
|
return;
|
|
47
35
|
}
|
|
48
|
-
// A lone file is saved as itself rather than zipped
|
|
49
|
-
//
|
|
36
|
+
// A lone file is saved as itself rather than zipped. Everything else, including the
|
|
37
|
+
// per-root archive split, belongs to compute.
|
|
50
38
|
if (filesArray.length === 1) {
|
|
51
|
-
saveSingleFile(filesArray[0]);
|
|
39
|
+
await saveSingleFile(filesArray[0]);
|
|
52
40
|
return;
|
|
53
41
|
}
|
|
54
|
-
await downloadFileDataByRoot(filesArray, fileName);
|
|
42
|
+
await downloadFileDataByRoot(filesArray, fileName, null, { saveBlob });
|
|
55
43
|
}
|
|
56
44
|
catch (error) {
|
|
57
45
|
console.error('[FileDownload] Error downloading files:', error);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Resolves false when the user cancelled. An anchor download always resolves true: it can't tell. */
|
|
2
|
+
export type BlobSaver = (blob: Blob, fileName: string) => Promise<boolean>;
|
|
3
|
+
/** `accept` is what `<input accept>` takes: `.slvp,.sps`, `image/*`. Resolves null on cancel. */
|
|
4
|
+
export type FilePicker = (options: {
|
|
5
|
+
accept: string;
|
|
6
|
+
}) => Promise<File | null>;
|
|
7
|
+
export declare function setBlobSaver(next: BlobSaver): void;
|
|
8
|
+
export declare function setFilePicker(next: FilePicker): void;
|
|
9
|
+
export declare function saveBlob(blob: Blob, fileName: string): Promise<boolean>;
|
|
10
|
+
export declare function pickFile(options: {
|
|
11
|
+
accept: string;
|
|
12
|
+
}): Promise<File | null>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Where a file the page offers goes, and where a file the page asks for comes from. A browser
|
|
2
|
+
// gets an <a download> click and a hidden <input type=file>. A host whose WebView can't do
|
|
3
|
+
// either safely (the Rhino panel) installs its own once at start-up, before the first save or pick.
|
|
4
|
+
let saver = saveWithAnchor;
|
|
5
|
+
let picker = pickWithInput;
|
|
6
|
+
export function setBlobSaver(next) {
|
|
7
|
+
saver = next;
|
|
8
|
+
}
|
|
9
|
+
export function setFilePicker(next) {
|
|
10
|
+
picker = next;
|
|
11
|
+
}
|
|
12
|
+
export function saveBlob(blob, fileName) {
|
|
13
|
+
return saver(blob, fileName);
|
|
14
|
+
}
|
|
15
|
+
export function pickFile(options) {
|
|
16
|
+
return picker(options);
|
|
17
|
+
}
|
|
18
|
+
async function saveWithAnchor(blob, fileName) {
|
|
19
|
+
const url = URL.createObjectURL(blob);
|
|
20
|
+
const a = document.createElement('a');
|
|
21
|
+
a.href = url;
|
|
22
|
+
a.download = fileName;
|
|
23
|
+
// Firefox only downloads from an anchor that is in the document.
|
|
24
|
+
document.body.appendChild(a);
|
|
25
|
+
a.click();
|
|
26
|
+
a.remove();
|
|
27
|
+
// Revoking synchronously can abort the download: the browser pins the blob only once it starts.
|
|
28
|
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
function pickWithInput({ accept }) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const input = document.createElement('input');
|
|
34
|
+
input.type = 'file';
|
|
35
|
+
input.accept = accept;
|
|
36
|
+
input.style.display = 'none';
|
|
37
|
+
const settle = (file) => {
|
|
38
|
+
input.remove();
|
|
39
|
+
resolve(file);
|
|
40
|
+
};
|
|
41
|
+
input.addEventListener('change', () => settle(input.files?.[0] ?? null), { once: true });
|
|
42
|
+
input.addEventListener('cancel', () => settle(null), { once: true });
|
|
43
|
+
document.body.appendChild(input);
|
|
44
|
+
input.click();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** True when a WebP should go out as PNG: the input takes PNG, so a PNG is a valid value. */
|
|
2
|
+
export declare function shouldConvertWebp(fileEnding: string, acceptedFormats: readonly string[]): boolean;
|
|
3
|
+
/** Whether the input takes a file with this ending, counting a WebP it would get as PNG. */
|
|
4
|
+
export declare function acceptsFileEnding(fileEnding: string, acceptedFormats: readonly string[]): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* By content, not name: image CDNs serve WebP from URLs with no extension or one naming another
|
|
7
|
+
* format. A WebP file starts `RIFF`, a 4-byte size, then `WEBP`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isWebp(blob: Blob): Promise<boolean>;
|
|
10
|
+
export declare function webpToPng(blob: Blob): Promise<Blob>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Grasshopper's PDF export can't read WebP: the plugin's PDF library has no WebP decoder, and
|
|
2
|
+
// nothing on Windows or Rhino.Compute decodes it reliably. The browser can, so a WebP picked for
|
|
3
|
+
// an input that also takes PNG is re-encoded here. PNG rather than JPEG keeps transparency.
|
|
4
|
+
/** True when a WebP should go out as PNG: the input takes PNG, so a PNG is a valid value. */
|
|
5
|
+
export function shouldConvertWebp(fileEnding, acceptedFormats) {
|
|
6
|
+
return fileEnding === '.webp' && acceptedFormats.includes('.png');
|
|
7
|
+
}
|
|
8
|
+
/** Whether the input takes a file with this ending, counting a WebP it would get as PNG. */
|
|
9
|
+
export function acceptsFileEnding(fileEnding, acceptedFormats) {
|
|
10
|
+
const ending = fileEnding.toLowerCase();
|
|
11
|
+
if (acceptedFormats.length === 0 || acceptedFormats.includes(ending))
|
|
12
|
+
return true;
|
|
13
|
+
return shouldConvertWebp(ending, acceptedFormats);
|
|
14
|
+
}
|
|
15
|
+
const ascii = (bytes, start, end) => String.fromCharCode(...bytes.subarray(start, end));
|
|
16
|
+
/**
|
|
17
|
+
* By content, not name: image CDNs serve WebP from URLs with no extension or one naming another
|
|
18
|
+
* format. A WebP file starts `RIFF`, a 4-byte size, then `WEBP`.
|
|
19
|
+
*/
|
|
20
|
+
export async function isWebp(blob) {
|
|
21
|
+
if (blob.type === 'image/webp')
|
|
22
|
+
return true;
|
|
23
|
+
const head = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
|
|
24
|
+
return head.length === 12 && ascii(head, 0, 4) === 'RIFF' && ascii(head, 8, 12) === 'WEBP';
|
|
25
|
+
}
|
|
26
|
+
export async function webpToPng(blob) {
|
|
27
|
+
const bitmap = await createImageBitmap(blob);
|
|
28
|
+
try {
|
|
29
|
+
const canvas = document.createElement('canvas');
|
|
30
|
+
canvas.width = bitmap.width;
|
|
31
|
+
canvas.height = bitmap.height;
|
|
32
|
+
const context = canvas.getContext('2d');
|
|
33
|
+
if (!context)
|
|
34
|
+
throw new Error('Canvas 2D context unavailable');
|
|
35
|
+
context.drawImage(bitmap, 0, 0);
|
|
36
|
+
return await new Promise((resolve, reject) => canvas.toBlob((png) => (png ? resolve(png) : reject(new Error('PNG encoding failed'))), 'image/png'));
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
bitmap.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "6.6.0-beta.
|
|
3
|
+
"version": "6.6.0-beta.9",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "VektorNode",
|
|
@@ -59,10 +59,10 @@
|
|
|
59
59
|
"svelte": "^5",
|
|
60
60
|
"tailwind-variants": "^3.3.1",
|
|
61
61
|
"three": "^0.186.1",
|
|
62
|
-
"@selvajs/compute": "^
|
|
63
|
-
"@selvajs/
|
|
64
|
-
"@selvajs/
|
|
65
|
-
"@selvajs/
|
|
62
|
+
"@selvajs/compute": "^5.0.0-beta.4",
|
|
63
|
+
"@selvajs/solve": "^2.0.0-beta.6",
|
|
64
|
+
"@selvajs/schemas": "^5.3.0-beta.1",
|
|
65
|
+
"@selvajs/visualization": "^1.6.0-beta.2"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
68
|
"three": {
|
|
@@ -90,11 +90,11 @@
|
|
|
90
90
|
"svelte": "5.57.1",
|
|
91
91
|
"tailwind-variants": "^3.3.1",
|
|
92
92
|
"vitest": "^5.0.2",
|
|
93
|
-
"@selvajs/compute": "
|
|
93
|
+
"@selvajs/compute": "5.0.0-beta.4",
|
|
94
94
|
"@selvajs/config": "0.0.4",
|
|
95
|
-
"@selvajs/
|
|
96
|
-
"@selvajs/
|
|
97
|
-
"@selvajs/
|
|
95
|
+
"@selvajs/schemas": "5.3.0-beta.1",
|
|
96
|
+
"@selvajs/visualization": "1.6.0-beta.2",
|
|
97
|
+
"@selvajs/solve": "2.0.0-beta.6"
|
|
98
98
|
},
|
|
99
99
|
"scripts": {
|
|
100
100
|
"predev": "node ../../scripts/sync-shared-assets.js",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
2
3
|
import type { UISchema, SupportedTypes, ParameterPreset } from '@selvajs/schemas';
|
|
3
4
|
import type { SolveSettlement } from '@selvajs/solve/client';
|
|
4
5
|
import type { ActionButton } from '../../types/actionButton';
|
|
@@ -34,6 +35,10 @@
|
|
|
34
35
|
meshes?: any[];
|
|
35
36
|
isSolving: boolean;
|
|
36
37
|
showSolvingIndicator?: boolean;
|
|
38
|
+
/** `SolvingIndicator`'s `placement`; `inline` puts it in the action row. */
|
|
39
|
+
solvingIndicatorPlacement?: 'floating' | 'inline';
|
|
40
|
+
/** Rendered above the action row, for a host that keeps the solve's messages in the flow. */
|
|
41
|
+
solveMessages?: Snippet;
|
|
37
42
|
hasPendingChanges?: boolean;
|
|
38
43
|
hasNeverSolved?: boolean;
|
|
39
44
|
isViewerFullscreen?: boolean;
|
|
@@ -63,6 +68,8 @@
|
|
|
63
68
|
meshes = [],
|
|
64
69
|
isSolving,
|
|
65
70
|
showSolvingIndicator = false,
|
|
71
|
+
solvingIndicatorPlacement = 'floating',
|
|
72
|
+
solveMessages,
|
|
66
73
|
hasPendingChanges = false,
|
|
67
74
|
hasNeverSolved = false,
|
|
68
75
|
isViewerFullscreen = $bindable(false),
|
|
@@ -248,6 +255,9 @@
|
|
|
248
255
|
<!-- One primary action and one menu. A definition that solves on every change has no Calculate,
|
|
249
256
|
so the menu sits alone at the end of the row. -->
|
|
250
257
|
{#snippet footer()}
|
|
258
|
+
{#if solveMessages}
|
|
259
|
+
<div class="px-2">{@render solveMessages()}</div>
|
|
260
|
+
{/if}
|
|
251
261
|
{#if schema.instanceSolve === false}
|
|
252
262
|
<CalculateButton
|
|
253
263
|
{hasPendingChanges}
|
|
@@ -256,9 +266,14 @@
|
|
|
256
266
|
{oncalculate}
|
|
257
267
|
trailing={hasMenu ? actionsMenu : undefined}
|
|
258
268
|
/>
|
|
259
|
-
{:else if hasMenu}
|
|
260
|
-
<div class="mt-3 pb-3 pt-2 px-2 flex
|
|
261
|
-
{
|
|
269
|
+
{:else if hasMenu || solvingIndicatorPlacement === 'inline'}
|
|
270
|
+
<div class="mt-3 pb-3 pt-2 px-2 gap-2 flex items-center border-t border-border/50">
|
|
271
|
+
{#if solvingIndicatorPlacement === 'inline'}
|
|
272
|
+
<SolvingIndicator show={showSolvingIndicator} placement="inline" />
|
|
273
|
+
{/if}
|
|
274
|
+
{#if hasMenu}
|
|
275
|
+
<div class="ml-auto">{@render actionsMenu()}</div>
|
|
276
|
+
{/if}
|
|
262
277
|
</div>
|
|
263
278
|
{/if}
|
|
264
279
|
{/snippet}
|
|
@@ -272,7 +287,7 @@
|
|
|
272
287
|
)}
|
|
273
288
|
<div class="panel-content-wrapper">
|
|
274
289
|
{#if schema.layout.type === 'tabbed'}
|
|
275
|
-
<TabLayout {schema} {values} {onValueChange} {panelFilter} {requestedTabId} />
|
|
290
|
+
<TabLayout {schema} {values} {onValueChange} {panelFilter} {requestedTabId} bare={isMobile} />
|
|
276
291
|
{/if}
|
|
277
292
|
{#if hostsFooter && !isMobile}
|
|
278
293
|
<div class="panel-footer px-1">
|
|
@@ -344,7 +359,7 @@
|
|
|
344
359
|
<!-- Inert while closed: the fields are clipped out of view, and tabbing into them
|
|
345
360
|
would scroll the drawer's insides around behind the handle. -->
|
|
346
361
|
<div class="drawer-content" inert={drawer === 'closed'}>
|
|
347
|
-
<div class="px-
|
|
362
|
+
<div class="px-2 pb-4">
|
|
348
363
|
{#if hasLeftPanel}
|
|
349
364
|
{@render panelContent(hasRightPanel ? 'left' : undefined, requestedLeftTabId, false)}
|
|
350
365
|
{/if}
|
|
@@ -509,7 +524,9 @@
|
|
|
509
524
|
</div>
|
|
510
525
|
{/if}
|
|
511
526
|
|
|
512
|
-
|
|
527
|
+
{#if solvingIndicatorPlacement === 'floating'}
|
|
528
|
+
<SolvingIndicator show={showSolvingIndicator} />
|
|
529
|
+
{/if}
|
|
513
530
|
</main>
|
|
514
531
|
|
|
515
532
|
<!-- The save, load and validation dialogs; the footer's menu opens them. -->
|
|
@@ -555,7 +572,13 @@
|
|
|
555
572
|
|
|
556
573
|
.mobile-root {
|
|
557
574
|
--drawer-closed-h: 60px;
|
|
558
|
-
--drawer-half-h:
|
|
575
|
+
--drawer-half-h: 60%;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/* On a phone the panels stack in one scroller. Held at the scroller's height, a long left
|
|
579
|
+
panel would spill over the right panel below it. */
|
|
580
|
+
.mobile-root .panel-content-wrapper {
|
|
581
|
+
height: auto;
|
|
559
582
|
}
|
|
560
583
|
|
|
561
584
|
.viewer-region {
|
|
@@ -633,6 +656,13 @@
|
|
|
633
656
|
padding: 0rem 1rem;
|
|
634
657
|
}
|
|
635
658
|
|
|
659
|
+
/* The sheet's top edge already separates it from the model; the desktop footer's extra
|
|
660
|
+
spacing only takes rows from the fields. */
|
|
661
|
+
.drawer-footer :global(> div) {
|
|
662
|
+
margin-top: 0;
|
|
663
|
+
padding-block: 0.5rem;
|
|
664
|
+
}
|
|
665
|
+
|
|
636
666
|
/* Touch gets 44px targets on the panel's controls; a fine pointer keeps the denser sizes. */
|
|
637
667
|
@media (pointer: coarse) {
|
|
638
668
|
.panel-content-wrapper
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
} from '../../schema/param-exporter';
|
|
11
11
|
import { Button, Input, Label, Textarea, Dialog, Callout, IssueList, toast } from '../primitives';
|
|
12
12
|
import { DEFAULT_PRESET_LABELS, type PresetLabels } from '../../types/presetLabels';
|
|
13
|
+
import { pickFile } from '../../utils/host-files';
|
|
13
14
|
|
|
14
15
|
interface Props {
|
|
15
16
|
schema: UISchema;
|
|
@@ -37,7 +38,6 @@
|
|
|
37
38
|
let showLoadDialog = $state(false);
|
|
38
39
|
let loadResult = $state<PresetLoadResult | null>(null);
|
|
39
40
|
let loadingPresetName = $state('');
|
|
40
|
-
let fileInputRef = $state<HTMLInputElement | null>(null);
|
|
41
41
|
|
|
42
42
|
let listedStates = $state<ParameterPreset[]>([]);
|
|
43
43
|
let isLoadingList = $state(false);
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
if (onSaveState) await onSaveState(state);
|
|
72
|
-
else exportStateAsJson(state);
|
|
72
|
+
else await exportStateAsJson(state);
|
|
73
73
|
showExportDialog = false;
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -86,19 +86,16 @@
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
async function
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
async function importFromFile() {
|
|
90
|
+
// .sps is the pre-rename extension; files with it exist on users' drives, so keep accepting it.
|
|
91
|
+
const file = await pickFile({ accept: '.slvp,.sps' });
|
|
92
|
+
if (!file) return;
|
|
92
93
|
|
|
93
94
|
try {
|
|
94
|
-
|
|
95
|
-
tryLoad(imported);
|
|
95
|
+
tryLoad(await importStateFromJson(file));
|
|
96
96
|
} catch (error) {
|
|
97
97
|
toast.error(t.loadImportError + (error as Error).message);
|
|
98
98
|
}
|
|
99
|
-
|
|
100
|
-
// Reset so re-picking the same file fires `change` again.
|
|
101
|
-
input.value = '';
|
|
102
99
|
}
|
|
103
100
|
|
|
104
101
|
function confirmLoad() {
|
|
@@ -132,8 +129,8 @@
|
|
|
132
129
|
}
|
|
133
130
|
|
|
134
131
|
function handleLoadClick() {
|
|
135
|
-
fileInputRef?.click();
|
|
136
132
|
showLoadDialog = false;
|
|
133
|
+
void importFromFile();
|
|
137
134
|
}
|
|
138
135
|
|
|
139
136
|
function selectListedState(preset: ParameterPreset) {
|
|
@@ -142,15 +139,6 @@
|
|
|
142
139
|
}
|
|
143
140
|
</script>
|
|
144
141
|
|
|
145
|
-
<!-- .sps is the pre-rename extension; files with it exist on users' drives, so keep accepting it. -->
|
|
146
|
-
<input
|
|
147
|
-
bind:this={fileInputRef}
|
|
148
|
-
type="file"
|
|
149
|
-
accept=".slvp,.sps"
|
|
150
|
-
onchange={handleImport}
|
|
151
|
-
class="hidden"
|
|
152
|
-
/>
|
|
153
|
-
|
|
154
142
|
<!-- Save Dialog -->
|
|
155
143
|
<Dialog.Root bind:open={showExportDialog}>
|
|
156
144
|
<Dialog.Content>
|
|
@@ -31,6 +31,13 @@
|
|
|
31
31
|
onabort: () => void;
|
|
32
32
|
onconfirm: () => void;
|
|
33
33
|
ondiscard: () => void;
|
|
34
|
+
/**
|
|
35
|
+
* See `createSolvingIndicator`. The live panel would only paint once the solve is over, so
|
|
36
|
+
* an Esc hint stands in for it; the result's messages still show.
|
|
37
|
+
*/
|
|
38
|
+
freezesDuringSolve?: boolean;
|
|
39
|
+
/** `floating` overlays the bottom centre; `inline` sits in the flow below the host's content. */
|
|
40
|
+
placement?: 'floating' | 'inline';
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
let {
|
|
@@ -39,7 +46,9 @@
|
|
|
39
46
|
diagnostics = [],
|
|
40
47
|
onabort,
|
|
41
48
|
onconfirm,
|
|
42
|
-
ondiscard
|
|
49
|
+
ondiscard,
|
|
50
|
+
freezesDuringSolve = false,
|
|
51
|
+
placement = 'floating'
|
|
43
52
|
}: Props = $props();
|
|
44
53
|
|
|
45
54
|
const locale = getLocaleContext();
|
|
@@ -145,6 +154,7 @@
|
|
|
145
154
|
$effect(() => {
|
|
146
155
|
if (phase === 'live') {
|
|
147
156
|
finishing = false;
|
|
157
|
+
if (freezesDuringSolve) return;
|
|
148
158
|
const timer = setTimeout(() => {
|
|
149
159
|
revealed = true;
|
|
150
160
|
revealedAt = performance.now();
|
|
@@ -247,9 +257,22 @@
|
|
|
247
257
|
|
|
248
258
|
<div class="sr-only" role="status" aria-live="polite">{announcement}</div>
|
|
249
259
|
|
|
250
|
-
{#if
|
|
260
|
+
{#if freezesDuringSolve && view === 'live'}
|
|
261
|
+
<!-- Painted before the solve takes the thread; Abort could not be clicked until it ends. Inline,
|
|
262
|
+
the solving chip carries the hint instead: a line here would resize the footer mid-solve. -->
|
|
263
|
+
{#if placement === 'floating'}
|
|
264
|
+
<p
|
|
265
|
+
class="bottom-4 text-xs fixed left-1/2 z-50 -translate-x-1/2 text-muted-foreground"
|
|
266
|
+
data-testid="solve-esc-hint"
|
|
267
|
+
>
|
|
268
|
+
{t.solveMessagesEscHint}
|
|
269
|
+
</p>
|
|
270
|
+
{/if}
|
|
271
|
+
{:else if visible}
|
|
251
272
|
<div
|
|
252
|
-
class="
|
|
273
|
+
class="p-3 border {placement === 'inline'
|
|
274
|
+
? 'mt-3 rounded-md bg-muted/40'
|
|
275
|
+
: 'bottom-4 shadow-lg w-lg fixed left-1/2 z-50 max-w-screen-inset -translate-x-1/2 rounded-lg bg-background'} {view ===
|
|
253
276
|
'blocked'
|
|
254
277
|
? 'border-destructive/50'
|
|
255
278
|
: ''}"
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
<script lang="ts">
|
|
14
14
|
import type { Snippet } from 'svelte';
|
|
15
15
|
import { page } from '$app/state';
|
|
16
|
+
import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
|
|
17
|
+
import Check from '@lucide/svelte/icons/check';
|
|
18
|
+
import * as DropdownMenu from '$lib/components/primitives/dropdown-menu/index.js';
|
|
16
19
|
|
|
17
20
|
interface SideNavProps {
|
|
18
21
|
items: SideNavItem[];
|
|
@@ -41,10 +44,12 @@
|
|
|
41
44
|
if (item.match === 'prefix') return path.startsWith(item.href);
|
|
42
45
|
return path === item.href;
|
|
43
46
|
}
|
|
47
|
+
|
|
48
|
+
const activeItem = $derived(items.find(isActive));
|
|
44
49
|
</script>
|
|
45
50
|
|
|
46
|
-
<!-- On a phone a 240px column would leave the page ~150px, so the nav
|
|
47
|
-
|
|
51
|
+
<!-- On a phone a 240px column would leave the page ~150px, so the nav collapses to one switcher
|
|
52
|
+
naming the current section; the header and footer drop out. -->
|
|
48
53
|
<aside
|
|
49
54
|
class={`sm:h-full sm:w-60 sm:overflow-y-auto sm:border-b-0 flex w-full shrink-0 flex-col border-b border-border bg-background ${
|
|
50
55
|
border ? 'sm:border-r' : ''
|
|
@@ -64,31 +69,67 @@
|
|
|
64
69
|
</div>
|
|
65
70
|
{/if}
|
|
66
71
|
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
<div class="py-2 sm:hidden px-(--page-px)">
|
|
73
|
+
<DropdownMenu.Root>
|
|
74
|
+
<DropdownMenu.Trigger>
|
|
75
|
+
{#snippet child({ props })}
|
|
76
|
+
<button
|
|
77
|
+
{...props}
|
|
78
|
+
class="min-h-11 gap-3 px-3 text-sm font-medium flex w-full items-center rounded-md border border-border bg-card text-left"
|
|
79
|
+
>
|
|
80
|
+
{#if activeItem?.icon}
|
|
81
|
+
{@const Icon = activeItem.icon}
|
|
82
|
+
<Icon class="size-4 shrink-0 opacity-70" />
|
|
83
|
+
{/if}
|
|
84
|
+
<span class="min-w-0 flex-1 truncate">
|
|
85
|
+
{#if eyebrow}<span class="text-muted-foreground">{eyebrow} /</span>{/if}
|
|
86
|
+
{activeItem?.label ?? ''}
|
|
87
|
+
</span>
|
|
88
|
+
<ChevronsUpDown class="size-4 shrink-0 opacity-60" />
|
|
89
|
+
</button>
|
|
90
|
+
{/snippet}
|
|
91
|
+
</DropdownMenu.Trigger>
|
|
92
|
+
<DropdownMenu.Content sideOffset={4}>
|
|
93
|
+
{#each items as item (item.href)}
|
|
94
|
+
{@const active = isActive(item)}
|
|
95
|
+
{@const Icon = item.icon}
|
|
96
|
+
<DropdownMenu.Item class="min-h-11">
|
|
97
|
+
{#snippet child({ props })}
|
|
98
|
+
<a href={item.href} aria-current={active ? 'page' : undefined} {...props}>
|
|
99
|
+
{#if Icon}<Icon class="size-4 opacity-70" />{/if}
|
|
100
|
+
<span class="flex-1">{item.label}</span>
|
|
101
|
+
{#if item.badge !== undefined}
|
|
102
|
+
<span class="font-mono text-2xs tabular-nums opacity-60">{item.badge}</span>
|
|
103
|
+
{/if}
|
|
104
|
+
{#if active}<Check class="size-4" aria-hidden="true" />{/if}
|
|
105
|
+
</a>
|
|
106
|
+
{/snippet}
|
|
107
|
+
</DropdownMenu.Item>
|
|
108
|
+
{/each}
|
|
109
|
+
</DropdownMenu.Content>
|
|
110
|
+
</DropdownMenu.Root>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<nav class="pb-4 pr-2 sm:block hidden flex-1 space-y-px">
|
|
70
114
|
{#each items as item (item.href)}
|
|
71
115
|
{@const active = isActive(item)}
|
|
72
116
|
{@const Icon = item.icon}
|
|
73
117
|
<a
|
|
74
118
|
href={item.href}
|
|
75
|
-
|
|
119
|
+
aria-current={active ? 'page' : undefined}
|
|
120
|
+
class={`group gap-2 py-1.5 pr-2.5 relative flex w-full items-center text-left transition-colors ${
|
|
76
121
|
active
|
|
77
|
-
? 'pl-
|
|
78
|
-
: '
|
|
122
|
+
? 'pl-4 rounded-r-md bg-accent text-accent-foreground'
|
|
123
|
+
: 'mx-2 pl-2.5 rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
|
79
124
|
}`}
|
|
80
125
|
>
|
|
81
126
|
{#if active}
|
|
82
|
-
<span
|
|
83
|
-
class="left-0 top-0 bottom-0 w-0.75 sm:block absolute hidden bg-primary"
|
|
84
|
-
aria-hidden="true"
|
|
85
|
-
></span>
|
|
127
|
+
<span class="left-0 top-0 bottom-0 w-0.75 absolute bg-primary" aria-hidden="true"></span>
|
|
86
128
|
{/if}
|
|
87
129
|
{#if Icon}
|
|
88
130
|
<Icon class="h-3.5 w-3.5 shrink-0 opacity-70" />
|
|
89
131
|
{/if}
|
|
90
|
-
<span class="text-sm font-medium
|
|
91
|
-
>
|
|
132
|
+
<span class="text-sm font-medium flex-1 truncate">{item.label}</span>
|
|
92
133
|
{#if item.badge !== undefined}
|
|
93
134
|
<span class="font-mono shrink-0 text-2xs tabular-nums opacity-60">{item.badge}</span>
|
|
94
135
|
{/if}
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
return window.Plotly;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
|
|
58
|
+
const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
|
|
59
|
+
|
|
57
60
|
function toggleFullscreen() {
|
|
58
61
|
if (!wrapperEl) return;
|
|
59
62
|
if (!document.fullscreenElement) {
|
|
@@ -211,19 +214,21 @@
|
|
|
211
214
|
</div>
|
|
212
215
|
{/if}
|
|
213
216
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
217
|
+
{#if fullscreenSupported}
|
|
218
|
+
<button
|
|
219
|
+
onclick={toggleFullscreen}
|
|
220
|
+
disabled={loading}
|
|
221
|
+
title={isFullscreen ? t.exitFullscreen : t.fullscreen}
|
|
222
|
+
aria-label={isFullscreen ? t.exitFullscreen : t.fullscreen}
|
|
223
|
+
class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-foreground transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
|
|
224
|
+
>
|
|
225
|
+
{#if isFullscreen}
|
|
226
|
+
<Minimize size={14} />
|
|
227
|
+
{:else}
|
|
228
|
+
<Maximize size={14} />
|
|
229
|
+
{/if}
|
|
230
|
+
</button>
|
|
231
|
+
{/if}
|
|
227
232
|
</div>
|
|
228
233
|
{/if}
|
|
229
234
|
</div>
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
</div>
|
|
70
70
|
{/snippet}
|
|
71
71
|
|
|
72
|
-
<section class="shadow-xs rounded-xl bg-card">
|
|
72
|
+
<section class="shadow-xs rounded-xl border bg-card">
|
|
73
73
|
{#if flat}
|
|
74
74
|
<div class="p-4">
|
|
75
75
|
{@render grid()}
|
|
@@ -160,6 +160,11 @@
|
|
|
160
160
|
grid-column: span var(--span) / span var(--span);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/* A control with no label above it lines up with the controls beside it, not their labels. */
|
|
164
|
+
.grid-cell:has(:global([data-control-line])) {
|
|
165
|
+
align-self: end;
|
|
166
|
+
}
|
|
167
|
+
|
|
163
168
|
/* An empty full-width row: the two row gaps around it already read as a break. */
|
|
164
169
|
.grid-break {
|
|
165
170
|
grid-column: 1 / -1;
|
|
@@ -62,7 +62,9 @@
|
|
|
62
62
|
|
|
63
63
|
const cfg = $derived(item.config ?? {});
|
|
64
64
|
const allowDownload = $derived(cfg.allowDownload ?? true);
|
|
65
|
-
|
|
65
|
+
// Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
|
|
66
|
+
const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
|
|
67
|
+
const allowFullscreen = $derived((cfg.allowFullscreen ?? true) && fullscreenSupported);
|
|
66
68
|
const backgroundColor = $derived(cfg.backgroundColor);
|
|
67
69
|
|
|
68
70
|
let wrapperEl = $state<HTMLDivElement | null>(null);
|