selva-shared 0.8.3 → 0.8.5
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/README.md +2 -0
- package/dist/components/AppLayout.svelte +9 -2
- package/dist/components/AppLayout.svelte.d.ts +4 -0
- package/dist/components/ComputeApp.svelte +256 -0
- package/dist/components/ComputeApp.svelte.d.ts +31 -0
- package/dist/components/StateManager.svelte +40 -10
- package/dist/components/StateManager.svelte.d.ts +4 -0
- package/dist/composables/useFooterItem.svelte.js +8 -6
- package/dist/contexts/footerContext.svelte.d.ts +1 -0
- package/dist/contexts/footerContext.svelte.js +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/types/actionButton.d.ts +8 -0
- package/dist/types/actionButton.js +1 -0
- package/dist/types/solveFn.d.ts +7 -0
- package/dist/types/solveFn.js +1 -0
- package/dist/utils/color.d.ts +1 -0
- package/dist/utils/color.js +38 -0
- package/package.json +1 -1
- package/src/lib/components/AppLayout.svelte +9 -2
- package/src/lib/components/ComputeApp.svelte +256 -0
- package/src/lib/components/StateManager.svelte +40 -10
- package/src/lib/composables/useFooterItem.svelte.ts +7 -6
- package/src/lib/contexts/footerContext.svelte.ts +1 -1
- package/src/lib/index.ts +4 -0
- package/src/lib/types/actionButton.ts +8 -0
- package/src/lib/types/solveFn.ts +11 -0
- package/src/lib/utils/color.ts +45 -0
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { UISchema, SupportedTypes } from '../types/generated';
|
|
3
|
+
import type { ActionButton } from '../types/actionButton';
|
|
3
4
|
import { ChevronUp } from '@lucide/svelte';
|
|
4
5
|
import Viewer from './Viewer.svelte';
|
|
5
6
|
import CalculateButton from './ui/CalculateButton.svelte';
|
|
@@ -25,6 +26,9 @@
|
|
|
25
26
|
values: Record<string, unknown>;
|
|
26
27
|
onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
|
|
27
28
|
onLoadValues?: () => void | Promise<void>;
|
|
29
|
+
stateManagerActions?: ActionButton[];
|
|
30
|
+
showSaveButton?: boolean;
|
|
31
|
+
showLoadButton?: boolean;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
let {
|
|
@@ -38,7 +42,10 @@
|
|
|
38
42
|
oncalculate = () => {},
|
|
39
43
|
values = $bindable({}),
|
|
40
44
|
onValueChange,
|
|
41
|
-
onLoadValues
|
|
45
|
+
onLoadValues,
|
|
46
|
+
stateManagerActions = [],
|
|
47
|
+
showSaveButton = true,
|
|
48
|
+
showLoadButton = true
|
|
42
49
|
}: Props = $props();
|
|
43
50
|
|
|
44
51
|
// ── Layout flags ─────────────────────────────────────────────────────────────
|
|
@@ -121,7 +128,7 @@
|
|
|
121
128
|
{#if showStateManager || (!isMobile && showCalculateButton && schema.instanceSolve === false)}
|
|
122
129
|
<div class="panel-footer px-3">
|
|
123
130
|
{#if showStateManager}
|
|
124
|
-
<StateManager {schema} currentValues={values} onLoadValues={handleLoadValues} />
|
|
131
|
+
<StateManager {schema} currentValues={values} onLoadValues={handleLoadValues} actions={stateManagerActions} {showSaveButton} {showLoadButton} />
|
|
125
132
|
{/if}
|
|
126
133
|
{#if !isMobile && showCalculateButton && schema.instanceSolve === false}
|
|
127
134
|
<CalculateButton {hasPendingChanges} {hasNeverSolved} {isSolving} {oncalculate} />
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { UISchema, SupportedTypes } from '../types/generated';
|
|
2
|
+
import type { ActionButton } from '../types/actionButton';
|
|
2
3
|
interface Props {
|
|
3
4
|
schema: UISchema;
|
|
4
5
|
meshes?: any[];
|
|
@@ -11,6 +12,9 @@ interface Props {
|
|
|
11
12
|
values: Record<string, unknown>;
|
|
12
13
|
onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
|
|
13
14
|
onLoadValues?: () => void | Promise<void>;
|
|
15
|
+
stateManagerActions?: ActionButton[];
|
|
16
|
+
showSaveButton?: boolean;
|
|
17
|
+
showLoadButton?: boolean;
|
|
14
18
|
}
|
|
15
19
|
declare const AppLayout: import("svelte").Component<Props, {}, "values" | "isViewerFullscreen">;
|
|
16
20
|
type AppLayout = ReturnType<typeof AppLayout>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import { page } from '$app/state';
|
|
4
|
+
import type { UISchema } from '../types/generated';
|
|
5
|
+
import type { ActionButton } from '../types/actionButton';
|
|
6
|
+
import type { SolveFn } from '../types/solveFn';
|
|
7
|
+
import { getDefaultValue } from '../utils/utils-shared';
|
|
8
|
+
import { createComputeThrottle } from '../utils/computeThrottle.svelte';
|
|
9
|
+
import { createSolvingIndicator } from '../utils/solving.svelte';
|
|
10
|
+
import { useFooterItem } from '../composables/useFooterItem.svelte';
|
|
11
|
+
import { hexToOklch } from '../utils/color';
|
|
12
|
+
import PageContainer from './layout/PageContainer.svelte';
|
|
13
|
+
import PageHeader from './layout/PageHeader.svelte';
|
|
14
|
+
import AppLayout from './AppLayout.svelte';
|
|
15
|
+
import StateDisplay from './ui/StateDisplay.svelte';
|
|
16
|
+
|
|
17
|
+
// ── Props ────────────────────────────────────────────────────────────────────
|
|
18
|
+
import type { Snippet } from 'svelte';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
schema: UISchema;
|
|
22
|
+
onSolve: SolveFn;
|
|
23
|
+
definitionKey?: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
isEmbedded?: boolean;
|
|
26
|
+
primaryColor?: string;
|
|
27
|
+
showModeToggle?: boolean;
|
|
28
|
+
stateManagerActions?: ActionButton[];
|
|
29
|
+
showSaveButton?: boolean;
|
|
30
|
+
showLoadButton?: boolean;
|
|
31
|
+
footerComponent?: any;
|
|
32
|
+
footerComponentProps?: () => Record<string, unknown>;
|
|
33
|
+
footerItemId?: string;
|
|
34
|
+
footerItemPriority?: number;
|
|
35
|
+
// Callback to expose the loadValues function to the parent
|
|
36
|
+
// Usage: bind:loadValues={myLoadFn} or onReady={({ loadValues }) => ...}
|
|
37
|
+
onReady?: (api: { loadValues: (values: Record<string, unknown>) => void }) => void;
|
|
38
|
+
// Snippets for custom layout
|
|
39
|
+
header?: Snippet;
|
|
40
|
+
children?: Snippet<[{ errors: string[]; warnings: string[] }]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let {
|
|
44
|
+
schema,
|
|
45
|
+
onSolve,
|
|
46
|
+
definitionKey = '',
|
|
47
|
+
title,
|
|
48
|
+
isEmbedded,
|
|
49
|
+
primaryColor,
|
|
50
|
+
showModeToggle = false,
|
|
51
|
+
stateManagerActions = [],
|
|
52
|
+
showSaveButton = true,
|
|
53
|
+
showLoadButton = true,
|
|
54
|
+
footerComponent,
|
|
55
|
+
footerComponentProps,
|
|
56
|
+
footerItemId = 'footer-item',
|
|
57
|
+
footerItemPriority = 0,
|
|
58
|
+
header,
|
|
59
|
+
children,
|
|
60
|
+
onReady
|
|
61
|
+
}: Props = $props();
|
|
62
|
+
|
|
63
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
64
|
+
function createInitialValues(s: UISchema) {
|
|
65
|
+
const v: Record<string, unknown> = {};
|
|
66
|
+
for (const input of s.inputs) {
|
|
67
|
+
v[input.id] = input.default ?? getDefaultValue(input.paramType);
|
|
68
|
+
}
|
|
69
|
+
for (const output of s.outputs) {
|
|
70
|
+
v[output.id] = null;
|
|
71
|
+
}
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Core state ───────────────────────────────────────────────────────────────
|
|
76
|
+
// svelte-ignore state_referenced_locally
|
|
77
|
+
let values = $state<Record<string, unknown>>(createInitialValues(schema));
|
|
78
|
+
let error = $state('');
|
|
79
|
+
let computeErrors = $state<string[]>([]);
|
|
80
|
+
let computeWarnings = $state<string[]>([]);
|
|
81
|
+
let meshes = $state<any[]>([]);
|
|
82
|
+
let pendingValues = $state<Record<string, unknown>>({});
|
|
83
|
+
// svelte-ignore state_referenced_locally
|
|
84
|
+
let hasPendingChanges = $state(schema?.instanceSolve === false);
|
|
85
|
+
// svelte-ignore state_referenced_locally
|
|
86
|
+
let hasNeverSolved = $state(schema?.instanceSolve === false);
|
|
87
|
+
let isViewerFullscreen = $state(false);
|
|
88
|
+
|
|
89
|
+
// ── Solve logic ──────────────────────────────────────────────────────────────
|
|
90
|
+
async function performSolveInternal(solveValues: Record<string, unknown>, signal: AbortSignal) {
|
|
91
|
+
try {
|
|
92
|
+
error = '';
|
|
93
|
+
computeErrors = [];
|
|
94
|
+
computeWarnings = [];
|
|
95
|
+
|
|
96
|
+
const result = await onSolve(solveValues, signal);
|
|
97
|
+
|
|
98
|
+
if (signal.aborted) return;
|
|
99
|
+
|
|
100
|
+
computeErrors = result.errors ?? [];
|
|
101
|
+
computeWarnings = result.warnings ?? [];
|
|
102
|
+
meshes = result.meshes ?? [];
|
|
103
|
+
|
|
104
|
+
Object.assign(values, result.outputs);
|
|
105
|
+
pendingValues = {};
|
|
106
|
+
hasPendingChanges = false;
|
|
107
|
+
hasNeverSolved = false;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err instanceof Error && err.name === 'AbortError') return;
|
|
110
|
+
error = err instanceof Error ? err.message : String(err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const computeThrottle = createComputeThrottle<Record<string, unknown>>(performSolveInternal, {
|
|
115
|
+
timeout: 60000
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
let solving = $derived(computeThrottle.isComputing);
|
|
119
|
+
const solvingIndicator = createSolvingIndicator(() => solving);
|
|
120
|
+
|
|
121
|
+
function performSolve() {
|
|
122
|
+
computeThrottle.trigger($state.snapshot(values));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function loadValues(incoming: Record<string, unknown>) {
|
|
126
|
+
Object.assign(values, incoming);
|
|
127
|
+
if (schema?.instanceSolve !== false) {
|
|
128
|
+
performSolve();
|
|
129
|
+
} else {
|
|
130
|
+
hasPendingChanges = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
$effect(() => {
|
|
135
|
+
onReady?.({ loadValues });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── Definition switching ─────────────────────────────────────────────────────
|
|
139
|
+
let previousDefinitionKey = $state('');
|
|
140
|
+
let isInitialLoad = $state(true);
|
|
141
|
+
|
|
142
|
+
$effect(() => {
|
|
143
|
+
const _ = definitionKey;
|
|
144
|
+
|
|
145
|
+
untrack(() => {
|
|
146
|
+
if (isInitialLoad) {
|
|
147
|
+
isInitialLoad = false;
|
|
148
|
+
previousDefinitionKey = definitionKey;
|
|
149
|
+
if (schema?.instanceSolve !== false) {
|
|
150
|
+
performSolve();
|
|
151
|
+
}
|
|
152
|
+
} else if (previousDefinitionKey !== definitionKey) {
|
|
153
|
+
meshes = [];
|
|
154
|
+
values = createInitialValues(schema);
|
|
155
|
+
error = '';
|
|
156
|
+
computeErrors = [];
|
|
157
|
+
computeWarnings = [];
|
|
158
|
+
if (schema && Object.keys(values).length > 0) {
|
|
159
|
+
performSolve();
|
|
160
|
+
}
|
|
161
|
+
previousDefinitionKey = definitionKey;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
167
|
+
async function handleValueChange(id: string, val: unknown) {
|
|
168
|
+
values[id] = val;
|
|
169
|
+
|
|
170
|
+
if (schema?.instanceSolve === false) {
|
|
171
|
+
pendingValues[id] = val;
|
|
172
|
+
hasPendingChanges = true;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
performSolve();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function handleCalculate() {
|
|
180
|
+
performSolve();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Footer item ──────────────────────────────────────────────────────────────
|
|
184
|
+
// Use untrack to read these static props without creating reactive dependencies.
|
|
185
|
+
// footerComponentProps is intentionally NOT untracked — it's a getter called every render.
|
|
186
|
+
const _footerItemId = untrack(() => footerItemId);
|
|
187
|
+
const _footerComponent = untrack(() => footerComponent);
|
|
188
|
+
const _footerItemPriority = untrack(() => footerItemPriority);
|
|
189
|
+
useFooterItem(
|
|
190
|
+
_footerItemId,
|
|
191
|
+
_footerComponent,
|
|
192
|
+
() => (_footerComponent ? (footerComponentProps?.() ?? {}) : {}),
|
|
193
|
+
'left',
|
|
194
|
+
_footerItemPriority
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// ── Embed + custom style ─────────────────────────────────────────────────────
|
|
198
|
+
let resolvedIsEmbedded = $derived(isEmbedded ?? page.url.searchParams.get('embed') === 'true');
|
|
199
|
+
let resolvedPrimaryColor = $derived(primaryColor ?? page.url.searchParams.get('primary'));
|
|
200
|
+
let customStyle = $derived(
|
|
201
|
+
resolvedPrimaryColor ? `--primary: ${hexToOklch(resolvedPrimaryColor)}` : ''
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
let pageTitle = $derived(title ?? (schema?.description || schema.name));
|
|
205
|
+
</script>
|
|
206
|
+
|
|
207
|
+
<div style={customStyle} style:display="contents">
|
|
208
|
+
{#if children}
|
|
209
|
+
{@render children({ errors: computeErrors, warnings: computeWarnings })}
|
|
210
|
+
{:else}
|
|
211
|
+
<PageContainer errors={computeErrors} warnings={computeWarnings}>
|
|
212
|
+
{#if header}
|
|
213
|
+
{@render header()}
|
|
214
|
+
{:else if !resolvedIsEmbedded}
|
|
215
|
+
<PageHeader title={pageTitle} {showModeToggle} />
|
|
216
|
+
{/if}
|
|
217
|
+
|
|
218
|
+
<div class="bg-background flex flex-1 flex-col overflow-hidden">
|
|
219
|
+
{#if error}
|
|
220
|
+
<div class="flex min-h-100 items-center justify-center p-8">
|
|
221
|
+
<StateDisplay type="error" size="medium" message={error} />
|
|
222
|
+
</div>
|
|
223
|
+
{:else if !schema}
|
|
224
|
+
<div class="flex min-h-100 items-center justify-center">
|
|
225
|
+
<StateDisplay type="loading" size="large" message="Loading schema..." />
|
|
226
|
+
</div>
|
|
227
|
+
{:else}
|
|
228
|
+
{#key definitionKey}
|
|
229
|
+
<AppLayout
|
|
230
|
+
{schema}
|
|
231
|
+
{meshes}
|
|
232
|
+
isSolving={solving}
|
|
233
|
+
showSolvingIndicator={schema.instanceSolve !== false && solvingIndicator.show}
|
|
234
|
+
{hasPendingChanges}
|
|
235
|
+
{hasNeverSolved}
|
|
236
|
+
bind:isViewerFullscreen
|
|
237
|
+
bind:values
|
|
238
|
+
{stateManagerActions}
|
|
239
|
+
{showSaveButton}
|
|
240
|
+
{showLoadButton}
|
|
241
|
+
onValueChange={handleValueChange}
|
|
242
|
+
oncalculate={handleCalculate}
|
|
243
|
+
onLoadValues={() => {
|
|
244
|
+
if (schema?.instanceSolve !== false) {
|
|
245
|
+
performSolve();
|
|
246
|
+
} else {
|
|
247
|
+
hasPendingChanges = true;
|
|
248
|
+
}
|
|
249
|
+
}}
|
|
250
|
+
/>
|
|
251
|
+
{/key}
|
|
252
|
+
{/if}
|
|
253
|
+
</div>
|
|
254
|
+
</PageContainer>
|
|
255
|
+
{/if}
|
|
256
|
+
</div>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { UISchema } from '../types/generated';
|
|
2
|
+
import type { ActionButton } from '../types/actionButton';
|
|
3
|
+
import type { SolveFn } from '../types/solveFn';
|
|
4
|
+
import type { Snippet } from 'svelte';
|
|
5
|
+
interface Props {
|
|
6
|
+
schema: UISchema;
|
|
7
|
+
onSolve: SolveFn;
|
|
8
|
+
definitionKey?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
isEmbedded?: boolean;
|
|
11
|
+
primaryColor?: string;
|
|
12
|
+
showModeToggle?: boolean;
|
|
13
|
+
stateManagerActions?: ActionButton[];
|
|
14
|
+
showSaveButton?: boolean;
|
|
15
|
+
showLoadButton?: boolean;
|
|
16
|
+
footerComponent?: any;
|
|
17
|
+
footerComponentProps?: () => Record<string, unknown>;
|
|
18
|
+
footerItemId?: string;
|
|
19
|
+
footerItemPriority?: number;
|
|
20
|
+
onReady?: (api: {
|
|
21
|
+
loadValues: (values: Record<string, unknown>) => void;
|
|
22
|
+
}) => void;
|
|
23
|
+
header?: Snippet;
|
|
24
|
+
children?: Snippet<[{
|
|
25
|
+
errors: string[];
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}]>;
|
|
28
|
+
}
|
|
29
|
+
declare const ComputeApp: import("svelte").Component<Props, {}, "">;
|
|
30
|
+
type ComputeApp = ReturnType<typeof ComputeApp>;
|
|
31
|
+
export default ComputeApp;
|
|
@@ -10,13 +10,25 @@
|
|
|
10
10
|
} from '../utils/param-exporter';
|
|
11
11
|
import { Button, Input, Label, Textarea, Dialog, Card } from '../components/ui';
|
|
12
12
|
|
|
13
|
+
import type { ActionButton } from '../types/actionButton';
|
|
14
|
+
|
|
13
15
|
interface Props {
|
|
14
16
|
schema: UISchema;
|
|
15
17
|
currentValues: Record<string, unknown>;
|
|
16
18
|
onLoadValues: (values: Record<string, unknown>) => void;
|
|
19
|
+
showSaveButton?: boolean;
|
|
20
|
+
showLoadButton?: boolean;
|
|
21
|
+
actions?: ActionButton[];
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
let {
|
|
24
|
+
let {
|
|
25
|
+
schema,
|
|
26
|
+
currentValues,
|
|
27
|
+
onLoadValues,
|
|
28
|
+
showSaveButton = true,
|
|
29
|
+
showLoadButton = true,
|
|
30
|
+
actions = []
|
|
31
|
+
}: Props = $props();
|
|
20
32
|
|
|
21
33
|
// Save dialog state
|
|
22
34
|
let showExportDialog = $state(false);
|
|
@@ -127,15 +139,33 @@
|
|
|
127
139
|
</script>
|
|
128
140
|
|
|
129
141
|
<div class="gap-2 mb-2 flex items-center justify-center">
|
|
130
|
-
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
{#if showSaveButton}
|
|
143
|
+
<Button variant="default" size="sm" onclick={openExportDialog}>
|
|
144
|
+
<Download class="mr-2 h-4 w-4" />
|
|
145
|
+
Save State
|
|
146
|
+
</Button>
|
|
147
|
+
{/if}
|
|
148
|
+
|
|
149
|
+
{#if showLoadButton}
|
|
150
|
+
<Button variant="outline" size="sm" onclick={openLoadDialog}>
|
|
151
|
+
<Upload class="mr-2 h-4 w-4" />
|
|
152
|
+
Load State
|
|
153
|
+
</Button>
|
|
154
|
+
{/if}
|
|
155
|
+
|
|
156
|
+
{#each actions as action (action.id)}
|
|
157
|
+
<Button
|
|
158
|
+
variant={action.variant ?? 'outline'}
|
|
159
|
+
size={action.size ?? 'sm'}
|
|
160
|
+
onclick={action.onclick}
|
|
161
|
+
>
|
|
162
|
+
{#if action.icon}
|
|
163
|
+
{@const IconComponent = action.icon}
|
|
164
|
+
<IconComponent class="mr-2 h-4 w-4" />
|
|
165
|
+
{/if}
|
|
166
|
+
{action.label}
|
|
167
|
+
</Button>
|
|
168
|
+
{/each}
|
|
139
169
|
|
|
140
170
|
<input
|
|
141
171
|
bind:this={fileInputRef}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { UISchema } from '../types/generated';
|
|
2
|
+
import type { ActionButton } from '../types/actionButton';
|
|
2
3
|
interface Props {
|
|
3
4
|
schema: UISchema;
|
|
4
5
|
currentValues: Record<string, unknown>;
|
|
5
6
|
onLoadValues: (values: Record<string, unknown>) => void;
|
|
7
|
+
showSaveButton?: boolean;
|
|
8
|
+
showLoadButton?: boolean;
|
|
9
|
+
actions?: ActionButton[];
|
|
6
10
|
}
|
|
7
11
|
declare const StateManager: import("svelte").Component<Props, {}, "">;
|
|
8
12
|
type StateManager = ReturnType<typeof StateManager>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { onMount, onDestroy } from 'svelte';
|
|
2
|
-
import {
|
|
1
|
+
import { getContext, onMount, onDestroy } from 'svelte';
|
|
2
|
+
import { FOOTER_CONTEXT_KEY } from '../contexts/footerContext.svelte';
|
|
3
3
|
/**
|
|
4
4
|
* Register a footer item component that reactively updates its props.
|
|
5
5
|
* The `getProps` function is called on every render, so returning reactive state
|
|
@@ -16,13 +16,15 @@ import { useFooter } from '../contexts/footerContext.svelte';
|
|
|
16
16
|
*/
|
|
17
17
|
export function useFooterItem(id, component, getProps, position = 'left', priority = 0, onClick) {
|
|
18
18
|
onMount(() => {
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
const store = getContext(FOOTER_CONTEXT_KEY);
|
|
20
|
+
if (!store || !component)
|
|
21
|
+
return;
|
|
22
|
+
store.register(id, component, getProps, position, priority, onClick);
|
|
21
23
|
});
|
|
22
24
|
onDestroy(() => {
|
|
23
25
|
try {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
+
const store = getContext(FOOTER_CONTEXT_KEY);
|
|
27
|
+
store?.unregister(id);
|
|
26
28
|
}
|
|
27
29
|
catch {
|
|
28
30
|
// Context may not exist during SSR cleanup — safe to ignore
|
|
@@ -13,5 +13,6 @@ export interface FooterStore {
|
|
|
13
13
|
register(id: string, component: any, getProps: () => Record<string, any>, position?: 'left' | 'right', priority?: number, onClick?: () => void): void;
|
|
14
14
|
unregister(id: string): void;
|
|
15
15
|
}
|
|
16
|
+
export declare const FOOTER_CONTEXT_KEY: unique symbol;
|
|
16
17
|
export declare function initializeFooterContext(): FooterStore;
|
|
17
18
|
export declare function useFooter(): FooterStore;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getContext, setContext } from 'svelte';
|
|
2
2
|
import { SvelteMap } from 'svelte/reactivity';
|
|
3
|
-
const FOOTER_CONTEXT_KEY = Symbol('footer-context');
|
|
3
|
+
export const FOOTER_CONTEXT_KEY = Symbol('footer-context');
|
|
4
4
|
export function initializeFooterContext() {
|
|
5
5
|
const items = new SvelteMap();
|
|
6
6
|
const store = {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { default as PageContainer } from './components/layout/PageContainer.svel
|
|
|
2
2
|
export { default as PageHeader } from './components/layout/PageHeader.svelte';
|
|
3
3
|
export { default as PageFooter } from './components/layout/PageFooter.svelte';
|
|
4
4
|
export { default as AppLayout } from './components/AppLayout.svelte';
|
|
5
|
+
export { default as ComputeApp } from './components/ComputeApp.svelte';
|
|
5
6
|
export { default as ErrorScreen } from './components/ErrorScreen.svelte';
|
|
6
7
|
export { default as TabLayout } from './components/preview/TabLayout.svelte';
|
|
7
8
|
export { default as InputControl } from './components/preview/InputControl.svelte';
|
|
@@ -14,6 +15,7 @@ export { default as SolvingIndicator } from './components/ui/SolvingIndicator.sv
|
|
|
14
15
|
export { default as ComputeMessages } from './components/ComputeMessages.svelte';
|
|
15
16
|
export * from './features/preview/handlers';
|
|
16
17
|
export * from './features/preview/notifications';
|
|
18
|
+
export * from './utils/color';
|
|
17
19
|
export * from './utils/debounce';
|
|
18
20
|
export * from './utils/utils-shared';
|
|
19
21
|
export * from './utils/file-download';
|
|
@@ -25,5 +27,7 @@ export * from './composables/useFooterItem.svelte';
|
|
|
25
27
|
export { themeStore } from './stores/themeStore.svelte';
|
|
26
28
|
export * from './utils';
|
|
27
29
|
export type * from './types/generated';
|
|
30
|
+
export type { ActionButton } from './types/actionButton';
|
|
31
|
+
export type { SolveFn, SolveResult } from './types/solveFn';
|
|
28
32
|
export { ACCEPTED_FILE_FORMATS } from './types/generated/schema';
|
|
29
33
|
export * from './themes';
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { default as PageContainer } from './components/layout/PageContainer.svel
|
|
|
3
3
|
export { default as PageHeader } from './components/layout/PageHeader.svelte';
|
|
4
4
|
export { default as PageFooter } from './components/layout/PageFooter.svelte';
|
|
5
5
|
export { default as AppLayout } from './components/AppLayout.svelte';
|
|
6
|
+
export { default as ComputeApp } from './components/ComputeApp.svelte';
|
|
6
7
|
// Error components
|
|
7
8
|
export { default as ErrorScreen } from './components/ErrorScreen.svelte';
|
|
8
9
|
// Preview components
|
|
@@ -20,6 +21,7 @@ export { default as ComputeMessages } from './components/ComputeMessages.svelte'
|
|
|
20
21
|
export * from './features/preview/handlers';
|
|
21
22
|
export * from './features/preview/notifications';
|
|
22
23
|
// Utilities
|
|
24
|
+
export * from './utils/color';
|
|
23
25
|
export * from './utils/debounce';
|
|
24
26
|
export * from './utils/utils-shared';
|
|
25
27
|
export * from './utils/file-download';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const hexToOklch: (hex: string | null) => string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Helper to convert hex to OKLCH
|
|
2
|
+
export const hexToOklch = (hex) => {
|
|
3
|
+
if (!hex)
|
|
4
|
+
return '';
|
|
5
|
+
// Normalize hex (remove # if present)
|
|
6
|
+
const normalized = hex.replace(/^#/, '');
|
|
7
|
+
// If it's not a valid hex color, return as-is
|
|
8
|
+
if (!/^[0-9A-Fa-f]{6}$/.test(normalized)) {
|
|
9
|
+
return hex;
|
|
10
|
+
}
|
|
11
|
+
// Parse hex to RGB
|
|
12
|
+
const r = parseInt(normalized.slice(0, 2), 16) / 255;
|
|
13
|
+
const g = parseInt(normalized.slice(2, 4), 16) / 255;
|
|
14
|
+
const b = parseInt(normalized.slice(4, 6), 16) / 255;
|
|
15
|
+
// Convert RGB to linear RGB
|
|
16
|
+
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
17
|
+
const lr = toLinear(r);
|
|
18
|
+
const lg = toLinear(g);
|
|
19
|
+
const lb = toLinear(b);
|
|
20
|
+
// Convert linear RGB to XYZ
|
|
21
|
+
const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
|
|
22
|
+
const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
|
|
23
|
+
const z = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
|
|
24
|
+
// Convert XYZ to OKLab
|
|
25
|
+
const l_ = Math.cbrt(0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z);
|
|
26
|
+
const m_ = Math.cbrt(0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z);
|
|
27
|
+
const s_ = Math.cbrt(0.0482003018 * x + 0.2643662691 * y + 0.633851707 * z);
|
|
28
|
+
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
|
|
29
|
+
const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
|
|
30
|
+
const b_lab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
|
|
31
|
+
// Convert to LCH
|
|
32
|
+
const C = Math.sqrt(a * a + b_lab * b_lab);
|
|
33
|
+
let H = (Math.atan2(b_lab, a) * 180) / Math.PI;
|
|
34
|
+
if (H < 0)
|
|
35
|
+
H += 360;
|
|
36
|
+
// Format as OKLCH (round to reasonable precision)
|
|
37
|
+
return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${H.toFixed(1)})`;
|
|
38
|
+
};
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { UISchema, SupportedTypes } from '../types/generated';
|
|
3
|
+
import type { ActionButton } from '../types/actionButton';
|
|
3
4
|
import { ChevronUp } from '@lucide/svelte';
|
|
4
5
|
import Viewer from './Viewer.svelte';
|
|
5
6
|
import CalculateButton from './ui/CalculateButton.svelte';
|
|
@@ -25,6 +26,9 @@
|
|
|
25
26
|
values: Record<string, unknown>;
|
|
26
27
|
onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
|
|
27
28
|
onLoadValues?: () => void | Promise<void>;
|
|
29
|
+
stateManagerActions?: ActionButton[];
|
|
30
|
+
showSaveButton?: boolean;
|
|
31
|
+
showLoadButton?: boolean;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
let {
|
|
@@ -38,7 +42,10 @@
|
|
|
38
42
|
oncalculate = () => {},
|
|
39
43
|
values = $bindable({}),
|
|
40
44
|
onValueChange,
|
|
41
|
-
onLoadValues
|
|
45
|
+
onLoadValues,
|
|
46
|
+
stateManagerActions = [],
|
|
47
|
+
showSaveButton = true,
|
|
48
|
+
showLoadButton = true
|
|
42
49
|
}: Props = $props();
|
|
43
50
|
|
|
44
51
|
// ── Layout flags ─────────────────────────────────────────────────────────────
|
|
@@ -121,7 +128,7 @@
|
|
|
121
128
|
{#if showStateManager || (!isMobile && showCalculateButton && schema.instanceSolve === false)}
|
|
122
129
|
<div class="panel-footer px-3">
|
|
123
130
|
{#if showStateManager}
|
|
124
|
-
<StateManager {schema} currentValues={values} onLoadValues={handleLoadValues} />
|
|
131
|
+
<StateManager {schema} currentValues={values} onLoadValues={handleLoadValues} actions={stateManagerActions} {showSaveButton} {showLoadButton} />
|
|
125
132
|
{/if}
|
|
126
133
|
{#if !isMobile && showCalculateButton && schema.instanceSolve === false}
|
|
127
134
|
<CalculateButton {hasPendingChanges} {hasNeverSolved} {isSolving} {oncalculate} />
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import { page } from '$app/state';
|
|
4
|
+
import type { UISchema } from '../types/generated';
|
|
5
|
+
import type { ActionButton } from '../types/actionButton';
|
|
6
|
+
import type { SolveFn } from '../types/solveFn';
|
|
7
|
+
import { getDefaultValue } from '../utils/utils-shared';
|
|
8
|
+
import { createComputeThrottle } from '../utils/computeThrottle.svelte';
|
|
9
|
+
import { createSolvingIndicator } from '../utils/solving.svelte';
|
|
10
|
+
import { useFooterItem } from '../composables/useFooterItem.svelte';
|
|
11
|
+
import { hexToOklch } from '../utils/color';
|
|
12
|
+
import PageContainer from './layout/PageContainer.svelte';
|
|
13
|
+
import PageHeader from './layout/PageHeader.svelte';
|
|
14
|
+
import AppLayout from './AppLayout.svelte';
|
|
15
|
+
import StateDisplay from './ui/StateDisplay.svelte';
|
|
16
|
+
|
|
17
|
+
// ── Props ────────────────────────────────────────────────────────────────────
|
|
18
|
+
import type { Snippet } from 'svelte';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
schema: UISchema;
|
|
22
|
+
onSolve: SolveFn;
|
|
23
|
+
definitionKey?: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
isEmbedded?: boolean;
|
|
26
|
+
primaryColor?: string;
|
|
27
|
+
showModeToggle?: boolean;
|
|
28
|
+
stateManagerActions?: ActionButton[];
|
|
29
|
+
showSaveButton?: boolean;
|
|
30
|
+
showLoadButton?: boolean;
|
|
31
|
+
footerComponent?: any;
|
|
32
|
+
footerComponentProps?: () => Record<string, unknown>;
|
|
33
|
+
footerItemId?: string;
|
|
34
|
+
footerItemPriority?: number;
|
|
35
|
+
// Callback to expose the loadValues function to the parent
|
|
36
|
+
// Usage: bind:loadValues={myLoadFn} or onReady={({ loadValues }) => ...}
|
|
37
|
+
onReady?: (api: { loadValues: (values: Record<string, unknown>) => void }) => void;
|
|
38
|
+
// Snippets for custom layout
|
|
39
|
+
header?: Snippet;
|
|
40
|
+
children?: Snippet<[{ errors: string[]; warnings: string[] }]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let {
|
|
44
|
+
schema,
|
|
45
|
+
onSolve,
|
|
46
|
+
definitionKey = '',
|
|
47
|
+
title,
|
|
48
|
+
isEmbedded,
|
|
49
|
+
primaryColor,
|
|
50
|
+
showModeToggle = false,
|
|
51
|
+
stateManagerActions = [],
|
|
52
|
+
showSaveButton = true,
|
|
53
|
+
showLoadButton = true,
|
|
54
|
+
footerComponent,
|
|
55
|
+
footerComponentProps,
|
|
56
|
+
footerItemId = 'footer-item',
|
|
57
|
+
footerItemPriority = 0,
|
|
58
|
+
header,
|
|
59
|
+
children,
|
|
60
|
+
onReady
|
|
61
|
+
}: Props = $props();
|
|
62
|
+
|
|
63
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
64
|
+
function createInitialValues(s: UISchema) {
|
|
65
|
+
const v: Record<string, unknown> = {};
|
|
66
|
+
for (const input of s.inputs) {
|
|
67
|
+
v[input.id] = input.default ?? getDefaultValue(input.paramType);
|
|
68
|
+
}
|
|
69
|
+
for (const output of s.outputs) {
|
|
70
|
+
v[output.id] = null;
|
|
71
|
+
}
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Core state ───────────────────────────────────────────────────────────────
|
|
76
|
+
// svelte-ignore state_referenced_locally
|
|
77
|
+
let values = $state<Record<string, unknown>>(createInitialValues(schema));
|
|
78
|
+
let error = $state('');
|
|
79
|
+
let computeErrors = $state<string[]>([]);
|
|
80
|
+
let computeWarnings = $state<string[]>([]);
|
|
81
|
+
let meshes = $state<any[]>([]);
|
|
82
|
+
let pendingValues = $state<Record<string, unknown>>({});
|
|
83
|
+
// svelte-ignore state_referenced_locally
|
|
84
|
+
let hasPendingChanges = $state(schema?.instanceSolve === false);
|
|
85
|
+
// svelte-ignore state_referenced_locally
|
|
86
|
+
let hasNeverSolved = $state(schema?.instanceSolve === false);
|
|
87
|
+
let isViewerFullscreen = $state(false);
|
|
88
|
+
|
|
89
|
+
// ── Solve logic ──────────────────────────────────────────────────────────────
|
|
90
|
+
async function performSolveInternal(solveValues: Record<string, unknown>, signal: AbortSignal) {
|
|
91
|
+
try {
|
|
92
|
+
error = '';
|
|
93
|
+
computeErrors = [];
|
|
94
|
+
computeWarnings = [];
|
|
95
|
+
|
|
96
|
+
const result = await onSolve(solveValues, signal);
|
|
97
|
+
|
|
98
|
+
if (signal.aborted) return;
|
|
99
|
+
|
|
100
|
+
computeErrors = result.errors ?? [];
|
|
101
|
+
computeWarnings = result.warnings ?? [];
|
|
102
|
+
meshes = result.meshes ?? [];
|
|
103
|
+
|
|
104
|
+
Object.assign(values, result.outputs);
|
|
105
|
+
pendingValues = {};
|
|
106
|
+
hasPendingChanges = false;
|
|
107
|
+
hasNeverSolved = false;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err instanceof Error && err.name === 'AbortError') return;
|
|
110
|
+
error = err instanceof Error ? err.message : String(err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const computeThrottle = createComputeThrottle<Record<string, unknown>>(performSolveInternal, {
|
|
115
|
+
timeout: 60000
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
let solving = $derived(computeThrottle.isComputing);
|
|
119
|
+
const solvingIndicator = createSolvingIndicator(() => solving);
|
|
120
|
+
|
|
121
|
+
function performSolve() {
|
|
122
|
+
computeThrottle.trigger($state.snapshot(values));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function loadValues(incoming: Record<string, unknown>) {
|
|
126
|
+
Object.assign(values, incoming);
|
|
127
|
+
if (schema?.instanceSolve !== false) {
|
|
128
|
+
performSolve();
|
|
129
|
+
} else {
|
|
130
|
+
hasPendingChanges = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
$effect(() => {
|
|
135
|
+
onReady?.({ loadValues });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── Definition switching ─────────────────────────────────────────────────────
|
|
139
|
+
let previousDefinitionKey = $state('');
|
|
140
|
+
let isInitialLoad = $state(true);
|
|
141
|
+
|
|
142
|
+
$effect(() => {
|
|
143
|
+
const _ = definitionKey;
|
|
144
|
+
|
|
145
|
+
untrack(() => {
|
|
146
|
+
if (isInitialLoad) {
|
|
147
|
+
isInitialLoad = false;
|
|
148
|
+
previousDefinitionKey = definitionKey;
|
|
149
|
+
if (schema?.instanceSolve !== false) {
|
|
150
|
+
performSolve();
|
|
151
|
+
}
|
|
152
|
+
} else if (previousDefinitionKey !== definitionKey) {
|
|
153
|
+
meshes = [];
|
|
154
|
+
values = createInitialValues(schema);
|
|
155
|
+
error = '';
|
|
156
|
+
computeErrors = [];
|
|
157
|
+
computeWarnings = [];
|
|
158
|
+
if (schema && Object.keys(values).length > 0) {
|
|
159
|
+
performSolve();
|
|
160
|
+
}
|
|
161
|
+
previousDefinitionKey = definitionKey;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
167
|
+
async function handleValueChange(id: string, val: unknown) {
|
|
168
|
+
values[id] = val;
|
|
169
|
+
|
|
170
|
+
if (schema?.instanceSolve === false) {
|
|
171
|
+
pendingValues[id] = val;
|
|
172
|
+
hasPendingChanges = true;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
performSolve();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function handleCalculate() {
|
|
180
|
+
performSolve();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Footer item ──────────────────────────────────────────────────────────────
|
|
184
|
+
// Use untrack to read these static props without creating reactive dependencies.
|
|
185
|
+
// footerComponentProps is intentionally NOT untracked — it's a getter called every render.
|
|
186
|
+
const _footerItemId = untrack(() => footerItemId);
|
|
187
|
+
const _footerComponent = untrack(() => footerComponent);
|
|
188
|
+
const _footerItemPriority = untrack(() => footerItemPriority);
|
|
189
|
+
useFooterItem(
|
|
190
|
+
_footerItemId,
|
|
191
|
+
_footerComponent,
|
|
192
|
+
() => (_footerComponent ? (footerComponentProps?.() ?? {}) : {}),
|
|
193
|
+
'left',
|
|
194
|
+
_footerItemPriority
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// ── Embed + custom style ─────────────────────────────────────────────────────
|
|
198
|
+
let resolvedIsEmbedded = $derived(isEmbedded ?? page.url.searchParams.get('embed') === 'true');
|
|
199
|
+
let resolvedPrimaryColor = $derived(primaryColor ?? page.url.searchParams.get('primary'));
|
|
200
|
+
let customStyle = $derived(
|
|
201
|
+
resolvedPrimaryColor ? `--primary: ${hexToOklch(resolvedPrimaryColor)}` : ''
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
let pageTitle = $derived(title ?? (schema?.description || schema.name));
|
|
205
|
+
</script>
|
|
206
|
+
|
|
207
|
+
<div style={customStyle} style:display="contents">
|
|
208
|
+
{#if children}
|
|
209
|
+
{@render children({ errors: computeErrors, warnings: computeWarnings })}
|
|
210
|
+
{:else}
|
|
211
|
+
<PageContainer errors={computeErrors} warnings={computeWarnings}>
|
|
212
|
+
{#if header}
|
|
213
|
+
{@render header()}
|
|
214
|
+
{:else if !resolvedIsEmbedded}
|
|
215
|
+
<PageHeader title={pageTitle} {showModeToggle} />
|
|
216
|
+
{/if}
|
|
217
|
+
|
|
218
|
+
<div class="bg-background flex flex-1 flex-col overflow-hidden">
|
|
219
|
+
{#if error}
|
|
220
|
+
<div class="flex min-h-100 items-center justify-center p-8">
|
|
221
|
+
<StateDisplay type="error" size="medium" message={error} />
|
|
222
|
+
</div>
|
|
223
|
+
{:else if !schema}
|
|
224
|
+
<div class="flex min-h-100 items-center justify-center">
|
|
225
|
+
<StateDisplay type="loading" size="large" message="Loading schema..." />
|
|
226
|
+
</div>
|
|
227
|
+
{:else}
|
|
228
|
+
{#key definitionKey}
|
|
229
|
+
<AppLayout
|
|
230
|
+
{schema}
|
|
231
|
+
{meshes}
|
|
232
|
+
isSolving={solving}
|
|
233
|
+
showSolvingIndicator={schema.instanceSolve !== false && solvingIndicator.show}
|
|
234
|
+
{hasPendingChanges}
|
|
235
|
+
{hasNeverSolved}
|
|
236
|
+
bind:isViewerFullscreen
|
|
237
|
+
bind:values
|
|
238
|
+
{stateManagerActions}
|
|
239
|
+
{showSaveButton}
|
|
240
|
+
{showLoadButton}
|
|
241
|
+
onValueChange={handleValueChange}
|
|
242
|
+
oncalculate={handleCalculate}
|
|
243
|
+
onLoadValues={() => {
|
|
244
|
+
if (schema?.instanceSolve !== false) {
|
|
245
|
+
performSolve();
|
|
246
|
+
} else {
|
|
247
|
+
hasPendingChanges = true;
|
|
248
|
+
}
|
|
249
|
+
}}
|
|
250
|
+
/>
|
|
251
|
+
{/key}
|
|
252
|
+
{/if}
|
|
253
|
+
</div>
|
|
254
|
+
</PageContainer>
|
|
255
|
+
{/if}
|
|
256
|
+
</div>
|
|
@@ -10,13 +10,25 @@
|
|
|
10
10
|
} from '../utils/param-exporter';
|
|
11
11
|
import { Button, Input, Label, Textarea, Dialog, Card } from '../components/ui';
|
|
12
12
|
|
|
13
|
+
import type { ActionButton } from '../types/actionButton';
|
|
14
|
+
|
|
13
15
|
interface Props {
|
|
14
16
|
schema: UISchema;
|
|
15
17
|
currentValues: Record<string, unknown>;
|
|
16
18
|
onLoadValues: (values: Record<string, unknown>) => void;
|
|
19
|
+
showSaveButton?: boolean;
|
|
20
|
+
showLoadButton?: boolean;
|
|
21
|
+
actions?: ActionButton[];
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
let {
|
|
24
|
+
let {
|
|
25
|
+
schema,
|
|
26
|
+
currentValues,
|
|
27
|
+
onLoadValues,
|
|
28
|
+
showSaveButton = true,
|
|
29
|
+
showLoadButton = true,
|
|
30
|
+
actions = []
|
|
31
|
+
}: Props = $props();
|
|
20
32
|
|
|
21
33
|
// Save dialog state
|
|
22
34
|
let showExportDialog = $state(false);
|
|
@@ -127,15 +139,33 @@
|
|
|
127
139
|
</script>
|
|
128
140
|
|
|
129
141
|
<div class="gap-2 mb-2 flex items-center justify-center">
|
|
130
|
-
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
{#if showSaveButton}
|
|
143
|
+
<Button variant="default" size="sm" onclick={openExportDialog}>
|
|
144
|
+
<Download class="mr-2 h-4 w-4" />
|
|
145
|
+
Save State
|
|
146
|
+
</Button>
|
|
147
|
+
{/if}
|
|
148
|
+
|
|
149
|
+
{#if showLoadButton}
|
|
150
|
+
<Button variant="outline" size="sm" onclick={openLoadDialog}>
|
|
151
|
+
<Upload class="mr-2 h-4 w-4" />
|
|
152
|
+
Load State
|
|
153
|
+
</Button>
|
|
154
|
+
{/if}
|
|
155
|
+
|
|
156
|
+
{#each actions as action (action.id)}
|
|
157
|
+
<Button
|
|
158
|
+
variant={action.variant ?? 'outline'}
|
|
159
|
+
size={action.size ?? 'sm'}
|
|
160
|
+
onclick={action.onclick}
|
|
161
|
+
>
|
|
162
|
+
{#if action.icon}
|
|
163
|
+
{@const IconComponent = action.icon}
|
|
164
|
+
<IconComponent class="mr-2 h-4 w-4" />
|
|
165
|
+
{/if}
|
|
166
|
+
{action.label}
|
|
167
|
+
</Button>
|
|
168
|
+
{/each}
|
|
139
169
|
|
|
140
170
|
<input
|
|
141
171
|
bind:this={fileInputRef}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { onMount, onDestroy } from 'svelte';
|
|
2
|
-
import {
|
|
1
|
+
import { getContext, onMount, onDestroy } from 'svelte';
|
|
2
|
+
import { type FooterStore, FOOTER_CONTEXT_KEY } from '$lib/contexts/footerContext.svelte';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Register a footer item component that reactively updates its props.
|
|
@@ -24,14 +24,15 @@ export function useFooterItem(
|
|
|
24
24
|
onClick?: () => void
|
|
25
25
|
) {
|
|
26
26
|
onMount(() => {
|
|
27
|
-
const
|
|
28
|
-
|
|
27
|
+
const store = getContext<FooterStore | undefined>(FOOTER_CONTEXT_KEY);
|
|
28
|
+
if (!store || !component) return;
|
|
29
|
+
store.register(id, component, getProps, position, priority, onClick);
|
|
29
30
|
});
|
|
30
31
|
|
|
31
32
|
onDestroy(() => {
|
|
32
33
|
try {
|
|
33
|
-
const
|
|
34
|
-
|
|
34
|
+
const store = getContext<FooterStore | undefined>(FOOTER_CONTEXT_KEY);
|
|
35
|
+
store?.unregister(id);
|
|
35
36
|
} catch {
|
|
36
37
|
// Context may not exist during SSR cleanup — safe to ignore
|
|
37
38
|
}
|
|
@@ -24,7 +24,7 @@ export interface FooterStore {
|
|
|
24
24
|
unregister(id: string): void;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const FOOTER_CONTEXT_KEY = Symbol('footer-context');
|
|
27
|
+
export const FOOTER_CONTEXT_KEY = Symbol('footer-context');
|
|
28
28
|
|
|
29
29
|
export function initializeFooterContext(): FooterStore {
|
|
30
30
|
const items = new SvelteMap<string, FooterItem>();
|
package/src/lib/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { default as PageContainer } from './components/layout/PageContainer.svel
|
|
|
3
3
|
export { default as PageHeader } from './components/layout/PageHeader.svelte';
|
|
4
4
|
export { default as PageFooter } from './components/layout/PageFooter.svelte';
|
|
5
5
|
export { default as AppLayout } from './components/AppLayout.svelte';
|
|
6
|
+
export { default as ComputeApp } from './components/ComputeApp.svelte';
|
|
6
7
|
|
|
7
8
|
// Error components
|
|
8
9
|
export { default as ErrorScreen } from './components/ErrorScreen.svelte';
|
|
@@ -25,6 +26,7 @@ export * from './features/preview/handlers';
|
|
|
25
26
|
export * from './features/preview/notifications';
|
|
26
27
|
|
|
27
28
|
// Utilities
|
|
29
|
+
export * from './utils/color';
|
|
28
30
|
export * from './utils/debounce';
|
|
29
31
|
export * from './utils/utils-shared';
|
|
30
32
|
export * from './utils/file-download';
|
|
@@ -44,6 +46,8 @@ export * from './utils';
|
|
|
44
46
|
|
|
45
47
|
// Re-export types from generated schema
|
|
46
48
|
export type * from './types/generated';
|
|
49
|
+
export type { ActionButton } from './types/actionButton';
|
|
50
|
+
export type { SolveFn, SolveResult } from './types/solveFn';
|
|
47
51
|
|
|
48
52
|
// Re-export constants from generated schema
|
|
49
53
|
export { ACCEPTED_FILE_FORMATS } from './types/generated/schema';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Helper to convert hex to OKLCH
|
|
2
|
+
export const hexToOklch = (hex: string | null): string => {
|
|
3
|
+
if (!hex) return '';
|
|
4
|
+
|
|
5
|
+
// Normalize hex (remove # if present)
|
|
6
|
+
const normalized = hex.replace(/^#/, '');
|
|
7
|
+
|
|
8
|
+
// If it's not a valid hex color, return as-is
|
|
9
|
+
if (!/^[0-9A-Fa-f]{6}$/.test(normalized)) {
|
|
10
|
+
return hex;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Parse hex to RGB
|
|
14
|
+
const r = parseInt(normalized.slice(0, 2), 16) / 255;
|
|
15
|
+
const g = parseInt(normalized.slice(2, 4), 16) / 255;
|
|
16
|
+
const b = parseInt(normalized.slice(4, 6), 16) / 255;
|
|
17
|
+
|
|
18
|
+
// Convert RGB to linear RGB
|
|
19
|
+
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
20
|
+
const lr = toLinear(r);
|
|
21
|
+
const lg = toLinear(g);
|
|
22
|
+
const lb = toLinear(b);
|
|
23
|
+
|
|
24
|
+
// Convert linear RGB to XYZ
|
|
25
|
+
const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
|
|
26
|
+
const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
|
|
27
|
+
const z = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
|
|
28
|
+
|
|
29
|
+
// Convert XYZ to OKLab
|
|
30
|
+
const l_ = Math.cbrt(0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z);
|
|
31
|
+
const m_ = Math.cbrt(0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z);
|
|
32
|
+
const s_ = Math.cbrt(0.0482003018 * x + 0.2643662691 * y + 0.633851707 * z);
|
|
33
|
+
|
|
34
|
+
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
|
|
35
|
+
const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
|
|
36
|
+
const b_lab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
|
|
37
|
+
|
|
38
|
+
// Convert to LCH
|
|
39
|
+
const C = Math.sqrt(a * a + b_lab * b_lab);
|
|
40
|
+
let H = (Math.atan2(b_lab, a) * 180) / Math.PI;
|
|
41
|
+
if (H < 0) H += 360;
|
|
42
|
+
|
|
43
|
+
// Format as OKLCH (round to reasonable precision)
|
|
44
|
+
return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${H.toFixed(1)})`;
|
|
45
|
+
};
|