@kupcad/ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +281 -2
  3. package/package.json +65 -3
  4. package/src/ConsolePanel.svelte +139 -0
  5. package/src/ExportMenu.svelte +36 -0
  6. package/src/ParamField.svelte +390 -0
  7. package/src/ParamsPanel.svelte +216 -0
  8. package/src/PreviewPanel.svelte +372 -0
  9. package/src/ShortcutsDialog.svelte +170 -0
  10. package/src/Splitter.svelte +148 -0
  11. package/src/Viewer.svelte +175 -0
  12. package/src/ViewerPane.svelte +745 -0
  13. package/src/base64.js +16 -0
  14. package/src/console.js +78 -0
  15. package/src/controls.css +183 -0
  16. package/src/index.js +40 -0
  17. package/src/measures.js +52 -0
  18. package/src/params.js +348 -0
  19. package/src/range.js +19 -0
  20. package/src/recent.js +44 -0
  21. package/src/run-status.js +78 -0
  22. package/src/settings.js +82 -0
  23. package/src/shortcuts.js +59 -0
  24. package/src/theme.js +131 -0
  25. package/src/tokens.css +70 -0
  26. package/src/values.svelte.js +13 -0
  27. package/src/view-settings.js +81 -0
  28. package/src/viewer/appearance.js +90 -0
  29. package/src/viewer/camera.js +100 -0
  30. package/src/viewer/dispose.js +30 -0
  31. package/src/viewer/environment.js +145 -0
  32. package/src/viewer/faces.js +311 -0
  33. package/src/viewer/hover-mark.js +125 -0
  34. package/src/viewer/index.js +7 -0
  35. package/src/viewer/model.js +293 -0
  36. package/src/viewer/motion.js +96 -0
  37. package/src/viewer/section.js +166 -0
  38. package/src/viewer/testing.js +90 -0
  39. package/src/viewer/theme.js +32 -0
  40. package/src/viewer/types.d.ts +136 -0
  41. package/src/viewer/view-cube.js +314 -0
  42. package/src/viewer/viewer.js +1027 -0
  43. package/src/viewer/wheel.js +55 -0
  44. package/src/viewport-socket.js +64 -0
  45. package/src/vscode.css +49 -0
package/src/params.js ADDED
@@ -0,0 +1,348 @@
1
+ // The parameter panel's logic, apart from its markup: which control a parameter (or a part of its
2
+ // value) gets, how the parameters group, which value a control shows, and whether a value given
3
+ // in the panel is one the script takes (its schema's rules, core/src/core/param_rules.zig's),
4
+ // checked before it leaves the panel; the run is the final check, and a frame names a value it
5
+ // refused, and where in it.
6
+
7
+ /** @import { ParamSpec, ParamValue, ParamValues, PathStep, Rule } from '@kupcad/protocol/types' */
8
+
9
+ /**
10
+ * A slider (with a number beside it) for a number between a min and a max; a number field for
11
+ * one without; a select for a choice of listed values; a toggle for a boolean; a text field; and
12
+ * for a value of parts: a list (an element's control or block each, added and removed), a tuple
13
+ * (a control per place) or a record (a control per field of a Map).
14
+ * @typedef {'slider' | 'number' | 'select' | 'toggle' | 'text' | 'list' | 'tuple' | 'record'} Control
15
+ */
16
+
17
+ /** @typedef {ParamValue} Value */
18
+
19
+ /**
20
+ * A value the panel won't give, where in it (`[1, 'depth']`), and why, the place written first
21
+ * (`[1][:depth] must be at least 0.1`).
22
+ * @typedef {{ error: string, path: PathStep[] }} Refusal
23
+ */
24
+
25
+ /**
26
+ * @param {Rule} rule
27
+ * @returns {Control}
28
+ */
29
+ export function controlOf(rule) {
30
+ if (rule.type === 'array') return rule.tuple ? 'tuple' : 'list';
31
+ if (rule.type === 'map') return 'record';
32
+ if (rule.in) return 'select';
33
+ if (rule.type === 'boolean') return 'toggle';
34
+ if (rule.type === 'string') return 'text';
35
+ return rule.min !== null && rule.max !== null ? 'slider' : 'number';
36
+ }
37
+
38
+ /**
39
+ * The places of a value of parts, each with its rule and what labels its control: a tuple's
40
+ * places (their titles, else their indexes) or a Map's fields (their titles, else their names).
41
+ * @param {Rule} rule a tuple's or a Map's
42
+ * @returns {{ step: PathStep, rule: Rule, label: string }[]}
43
+ */
44
+ export function partsOf(rule) {
45
+ if (rule.tuple) {
46
+ return rule.tuple.map((place, index) => ({
47
+ step: index,
48
+ rule: place,
49
+ label: place.title ?? String(index),
50
+ }));
51
+ }
52
+ return (rule.fields ?? []).map((field) => ({
53
+ step: field.name,
54
+ rule: field,
55
+ label: field.title ?? field.name,
56
+ }));
57
+ }
58
+
59
+ /**
60
+ * A number control's increment: the script's `step:`, else 1 for one between a `min` and a
61
+ * `max` (a slider moved by a hand lands on whole numbers, not on 27.41112), or, for a range
62
+ * narrower than 1, which a step of 1 couldn't move in, the power of ten that gives it at least
63
+ * ten positions; any value for a number with no range.
64
+ * @param {Rule} rule
65
+ * @returns {number | 'any'}
66
+ */
67
+ export function stepOf(rule) {
68
+ if (rule.step !== null) return rule.step;
69
+ const { min, max } = rule;
70
+ if (min === null || max === null || !(max > min)) return 'any';
71
+ const range = max - min;
72
+ return range >= 1 ? 1 : 10 ** (Math.floor(Math.log10(range)) - 1);
73
+ }
74
+
75
+ /**
76
+ * The parameters by their `group:`, the groups in the order the script first names them: those
77
+ * without one first, under no heading.
78
+ * @param {ParamSpec[]} parameters
79
+ * @returns {{ group: string | null, parameters: ParamSpec[] }[]}
80
+ */
81
+ export function groupsOf(parameters) {
82
+ /** @type {Map<string | null, ParamSpec[]>} */
83
+ const groups = new Map([[null, []]]);
84
+ for (const spec of parameters) {
85
+ const members = groups.get(spec.group) ?? [];
86
+ members.push(spec);
87
+ groups.set(spec.group, members);
88
+ }
89
+ return [...groups]
90
+ .filter(([, members]) => members.length > 0)
91
+ .map(([group, members]) => ({ group, parameters: members }));
92
+ }
93
+
94
+ /**
95
+ * The value a parameter's control shows: the one set in the panel, else the one the run took,
96
+ * else its default (null when it has none, or the script computes it and no run took one).
97
+ * @param {ParamSpec} spec
98
+ * @param {ParamValues} overrides
99
+ * @param {ParamValues} values
100
+ * @returns {Value | null}
101
+ */
102
+ export function shownValue(spec, overrides, values) {
103
+ return ownValue(overrides, spec.name) ?? ownValue(values, spec.name) ?? spec.default_value;
104
+ }
105
+
106
+ /**
107
+ * Whether the panel sets the parameter `name`: by its own key, so a parameter named like an
108
+ * Object's member (`constructor`) isn't taken for set.
109
+ * @param {ParamValues} overrides
110
+ * @param {string} name
111
+ */
112
+ export const isOverridden = (overrides, name) => Object.hasOwn(overrides, name);
113
+
114
+ /**
115
+ * The value `values` holds under `name` itself, not one an Object has (`constructor` read a
116
+ * function).
117
+ * @param {ParamValues} values
118
+ * @param {string} name
119
+ * @returns {Value | undefined}
120
+ */
121
+ const ownValue = (values, name) => (Object.hasOwn(values, name) ? values[name] : undefined);
122
+
123
+ /**
124
+ * What a new part of `rule` starts as: its first choice, else what is blank for its kind (a
125
+ * number in its range, each of a tuple's places, each of a Map's fields).
126
+ * @param {Rule} rule
127
+ * @returns {Value}
128
+ */
129
+ export function blankOf(rule) {
130
+ if (rule.in) return rule.in[0];
131
+ switch (rule.type) {
132
+ case 'number': {
133
+ // Zero, into its range
134
+ return Math.min(Math.max(0, rule.min ?? -Infinity), rule.max ?? Infinity);
135
+ }
136
+ case 'boolean':
137
+ return false;
138
+ case 'array':
139
+ return rule.tuple ? rule.tuple.map(blankOf) : [];
140
+ case 'map':
141
+ return Object.fromEntries((rule.fields ?? []).map((field) => [field.name, blankOf(field)]));
142
+ default:
143
+ return '';
144
+ }
145
+ }
146
+
147
+ /**
148
+ * What a new element of a list starts as: a copy of the last, else a blank one.
149
+ * @param {Rule} rule a list's
150
+ * @param {readonly Value[]} items
151
+ * @returns {Value}
152
+ */
153
+ export function newElement(rule, items) {
154
+ if (items.length > 0) return structuredClone(items[items.length - 1]);
155
+ return blankOf(/** @type {Rule} */ (rule.items));
156
+ }
157
+
158
+ /**
159
+ * Whether a list may have an element added, or removed: its `max_items` and `min_items`.
160
+ * @param {Rule} rule a list's
161
+ * @param {number} count its elements now
162
+ */
163
+ export const canAdd = (rule, count) => rule.max_items === null || count < rule.max_items;
164
+ /** @param {Rule} rule @param {number} count */
165
+ export const canRemove = (rule, count) => rule.min_items === null || count > rule.min_items;
166
+
167
+ /**
168
+ * Whether `a` and `b` are one value: arrays and objects by what they hold.
169
+ * @param {Value | null | undefined} a
170
+ * @param {Value | null | undefined} b
171
+ * @returns {boolean}
172
+ */
173
+ export function sameValue(a, b) {
174
+ if (Array.isArray(a) || Array.isArray(b)) {
175
+ return (
176
+ Array.isArray(a) &&
177
+ Array.isArray(b) &&
178
+ a.length === b.length &&
179
+ a.every((item, index) => sameValue(item, b[index]))
180
+ );
181
+ }
182
+ if (isRecord(a) || isRecord(b)) {
183
+ if (!isRecord(a) || !isRecord(b)) return false;
184
+ const keys = Object.keys(a);
185
+ return (
186
+ keys.length === Object.keys(b).length &&
187
+ keys.every((key) => Object.hasOwn(b, key) && sameValue(a[key], b[key]))
188
+ );
189
+ }
190
+ return a === b;
191
+ }
192
+
193
+ /**
194
+ * @param {unknown} value
195
+ * @returns {value is { [field: string]: Value }}
196
+ */
197
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
198
+
199
+ /**
200
+ * `value` (the whole of it) with the part at `path` given `part`.
201
+ * @param {Value | null} value
202
+ * @param {PathStep[]} path
203
+ * @param {Value} part
204
+ * @returns {Value}
205
+ */
206
+ export function withPart(value, path, part) {
207
+ if (path.length === 0) return part;
208
+ const [step, ...rest] = path;
209
+ if (typeof step === 'number') {
210
+ const items = Array.isArray(value) ? [...value] : [];
211
+ items[step] = withPart(items[step] ?? null, rest, part);
212
+ return items;
213
+ }
214
+ const fields = isRecord(value) ? { ...value } : {};
215
+ fields[step] = withPart(Object.hasOwn(fields, step) ? fields[step] : null, rest, part);
216
+ return fields;
217
+ }
218
+
219
+ /**
220
+ * The part of `value` at `path`; null where it has none.
221
+ * @param {Value | null} value
222
+ * @param {PathStep[]} path
223
+ * @returns {Value | null}
224
+ */
225
+ export function partAt(value, path) {
226
+ let part = value;
227
+ for (const step of path) {
228
+ if (typeof step === 'number') part = Array.isArray(part) ? (part[step] ?? null) : null;
229
+ else part = isRecord(part) && Object.hasOwn(part, step) ? part[step] : null;
230
+ }
231
+ return part;
232
+ }
233
+
234
+ /**
235
+ * The place `path` names, as the core writes it: `[1][:depth]`.
236
+ * @param {PathStep[]} path
237
+ */
238
+ export const placeOf = (path) =>
239
+ path.map((step) => (typeof step === 'number' ? `[${step}]` : `[:${step}]`)).join('');
240
+
241
+ /**
242
+ * What a control gave, as the parameter's value (a part of it, for a part's rule), or why the
243
+ * script wouldn't take it and where.
244
+ * @param {Rule} rule
245
+ * @param {Value | null} input
246
+ * @returns {{ value: Value } | Refusal}
247
+ */
248
+ export function validate(rule, input) {
249
+ const result = read(rule, input, []);
250
+ if ('value' in result) return result;
251
+ const place = placeOf(result.path);
252
+ return { error: place ? `${place} ${result.error}` : result.error, path: result.path };
253
+ }
254
+
255
+ /**
256
+ * @param {Rule} rule
257
+ * @param {Value | null} input
258
+ * @param {PathStep[]} path where `input` is in the value
259
+ * @returns {{ value: Value } | Refusal}
260
+ */
261
+ function read(rule, input, path) {
262
+ if (rule.type === 'array') return readArray(rule, input, path);
263
+ if (rule.type === 'map') return readMap(rule, input, path);
264
+ const choices = rule.in;
265
+ if (choices) {
266
+ const choice = choices.find((listed) => String(listed) === String(input));
267
+ return choice === undefined
268
+ ? { error: `must be one of ${choices.join(', ')}`, path }
269
+ : { value: choice };
270
+ }
271
+ if (rule.type === 'boolean') return { value: input === true || input === 'true' };
272
+ if (rule.type === 'string') return { value: String(input ?? '') };
273
+ const text = String(input ?? '').trim();
274
+ const number = Number(text);
275
+ if (text === '' || !Number.isFinite(number)) return { error: 'must be a number', path };
276
+ if (rule.min !== null && number < rule.min)
277
+ return { error: `must be at least ${rule.min}`, path };
278
+ if (rule.max !== null && number > rule.max) return { error: `must be at most ${rule.max}`, path };
279
+ return { value: number };
280
+ }
281
+
282
+ /**
283
+ * @param {Rule} rule
284
+ * @param {Value | null} input
285
+ * @param {PathStep[]} path
286
+ * @returns {{ value: Value } | Refusal}
287
+ */
288
+ function readArray(rule, input, path) {
289
+ if (!Array.isArray(input)) return { error: 'must be a list', path };
290
+ if (rule.tuple && input.length !== rule.tuple.length) {
291
+ return { error: `must have ${rule.tuple.length} items`, path };
292
+ }
293
+ if (rule.min_items !== null && input.length < rule.min_items) {
294
+ return { error: `must have at least ${rule.min_items} items`, path };
295
+ }
296
+ if (rule.max_items !== null && input.length > rule.max_items) {
297
+ return { error: `must have at most ${rule.max_items} items`, path };
298
+ }
299
+ /** @type {Value[]} */
300
+ const items = [];
301
+ for (const [index, item] of input.entries()) {
302
+ const place = rule.tuple ? rule.tuple[index] : /** @type {Rule} */ (rule.items);
303
+ const result = read(place, item, [...path, index]);
304
+ if (!('value' in result)) return result;
305
+ items.push(result.value);
306
+ }
307
+ return { value: items };
308
+ }
309
+
310
+ /**
311
+ * @param {Rule} rule
312
+ * @param {Value | null} input
313
+ * @param {PathStep[]} path
314
+ * @returns {{ value: Value } | Refusal}
315
+ */
316
+ function readMap(rule, input, path) {
317
+ if (!isRecord(input)) return { error: 'must be a record', path };
318
+ /** @type {{ [field: string]: Value }} */
319
+ const fields = {};
320
+ for (const field of rule.fields ?? []) {
321
+ const given = Object.hasOwn(input, field.name) ? input[field.name] : null;
322
+ const result = read(field, given, [...path, field.name]);
323
+ if (!('value' in result)) return result;
324
+ fields[field.name] = result.value;
325
+ }
326
+ return { value: fields };
327
+ }
328
+
329
+ /**
330
+ * `overrides` with `name` set to `value`.
331
+ * @param {ParamValues} overrides
332
+ * @param {string} name
333
+ * @param {Value} value
334
+ * @returns {ParamValues}
335
+ */
336
+ export function withOverride(overrides, name, value) {
337
+ return { ...overrides, [name]: value };
338
+ }
339
+
340
+ /**
341
+ * `overrides` without `name`: the parameter takes the script's value again.
342
+ * @param {ParamValues} overrides
343
+ * @param {string} name
344
+ * @returns {ParamValues}
345
+ */
346
+ export function withoutOverride(overrides, name) {
347
+ return Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== name));
348
+ }
package/src/range.js ADDED
@@ -0,0 +1,19 @@
1
+ // How far along a range input's thumb is, for controls.css's .kc-range: WebKit and Blink fill
2
+ // no track of their own, so the panel sets this as --kc-range-fill as the value changes.
3
+
4
+ /**
5
+ * `value`'s place between `min` and `max` as a CSS percentage, held to 0% and 100%. A range
6
+ * input with no value puts its thumb half way, and so does this; a span that is none, at 0%.
7
+ * @param {unknown} value
8
+ * @param {number | string | null} [min] the input's (none: a browser's default, 0)
9
+ * @param {number | string | null} [max] the input's (none: a browser's default, 100)
10
+ * @returns {string}
11
+ */
12
+ export function fillOf(value, min, max) {
13
+ const low = Number(min ?? 0);
14
+ const high = Number(max ?? 100);
15
+ if (!Number.isFinite(low) || !Number.isFinite(high) || high <= low) return '0%';
16
+ const at = value === null || value === undefined || value === '' ? NaN : Number(value);
17
+ if (!Number.isFinite(at)) return '50%';
18
+ return `${Math.min(100, Math.max(0, ((at - low) / (high - low)) * 100))}%`;
19
+ }
package/src/recent.js ADDED
@@ -0,0 +1,44 @@
1
+ // What a pane keeps of each thing it showed, for the next time it shows it: the most recently
2
+ // shown kept, as many as it keeps, the least recently shown let go past them. Only for as long
3
+ // as the pane is there: nothing is written anywhere.
4
+
5
+ /** @template T */
6
+ export class Recent {
7
+ /** @type {Map<string, T>} */
8
+ #kept = new Map();
9
+ #most;
10
+
11
+ /** @param {number} most how many it keeps */
12
+ constructor(most) {
13
+ this.#most = most;
14
+ }
15
+
16
+ /**
17
+ * Keeps `value` for `key`, the most recent; the least recent goes past the most kept.
18
+ * @param {string} key
19
+ * @param {T} value
20
+ */
21
+ keep(key, value) {
22
+ this.#kept.delete(key);
23
+ this.#kept.set(key, value);
24
+ for (const oldest of this.#kept.keys()) {
25
+ if (this.#kept.size <= this.#most) break;
26
+ this.#kept.delete(oldest);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * What is kept for `key`, the most recent now; none for a key not kept, or let go.
32
+ * @param {string} key
33
+ * @returns {T | undefined}
34
+ */
35
+ get(key) {
36
+ const value = this.#kept.get(key);
37
+ if (value !== undefined) this.keep(key, value);
38
+ return value;
39
+ }
40
+
41
+ get size() {
42
+ return this.#kept.size;
43
+ }
44
+ }
@@ -0,0 +1,78 @@
1
+ // How the run a viewer shows went, for its status line, and what it said went wrong: from the
2
+ // preview frame's header, the same for every host.
3
+
4
+ /** @import { FrameHeader } from '@kupcad/protocol/types' */
5
+
6
+ /**
7
+ * The run of the script shown: running, done, or failed; how long it took (ms), and how many
8
+ * warnings it gave.
9
+ * @typedef {object} RunStatus
10
+ * @property {'running' | 'ready' | 'failed'} state
11
+ * @property {number} [runMs]
12
+ * @property {number} [warnings]
13
+ */
14
+
15
+ /**
16
+ * Something the run said went wrong: why it failed, or a warning, and where in the script it
17
+ * was (a 1-based line and column, in UTF-8 bytes), null where it wasn't running.
18
+ * @typedef {object} Problem
19
+ * @property {'error' | 'warning'} severity
20
+ * @property {string} message
21
+ * @property {number | null} line
22
+ * @property {number | null} column
23
+ */
24
+
25
+ /** @type {Record<RunStatus['state'], string>} */
26
+ const STATES = { running: 'Running…', ready: 'Ready', failed: 'Failed' };
27
+
28
+ /**
29
+ * The run whose frame is `header`: running while none came (null).
30
+ * @param {FrameHeader | null} header
31
+ * @returns {RunStatus}
32
+ */
33
+ export function runStatusOf(header) {
34
+ if (!header) return { state: 'running' };
35
+ return {
36
+ state: header.error ? 'failed' : 'ready',
37
+ runMs: header.timing.runMs,
38
+ warnings: header.warnings.length,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * What the status line says of `run`: its state, then its time and its warnings.
44
+ * @param {RunStatus} run
45
+ */
46
+ export function describeRun(run) {
47
+ const said = [STATES[run.state]];
48
+ if (run.runMs !== undefined) said.push(`${Math.round(run.runMs)} ms`);
49
+ if (run.warnings) said.push(`${run.warnings} warning${run.warnings === 1 ? '' : 's'}`);
50
+ return said.join(' · ');
51
+ }
52
+
53
+ /**
54
+ * What the run whose frame is `header` said went wrong: why it failed first, then its warnings;
55
+ * nothing while none came (null).
56
+ * @param {FrameHeader | null} header
57
+ * @returns {Problem[]}
58
+ */
59
+ export function problemsOf(header) {
60
+ if (!header) return [];
61
+ /** @type {Problem[]} */
62
+ const problems = header.warnings.map(({ message, line, column }) => ({
63
+ severity: 'warning',
64
+ message,
65
+ line,
66
+ column,
67
+ }));
68
+ const { error } = header;
69
+ if (error) {
70
+ problems.unshift({
71
+ severity: 'error',
72
+ message: error.message,
73
+ line: error.line,
74
+ column: error.column,
75
+ });
76
+ }
77
+ return problems;
78
+ }
@@ -0,0 +1,82 @@
1
+ // What a person set in a host's panels (the panes' sizes, whether they show), kept where the
2
+ // host keeps such things. The playground keeps it in the page's localStorage (localSettings),
3
+ // the VS Code extension and the desktop app their own way, each on memorySettings; a host that
4
+ // keeps nothing gives memorySettings alone.
5
+ //
6
+ // Settings are reactive: a pane reads its value in a $derived, and follows it as it is set,
7
+ // wherever from. A size dragged is the pane's own until the drag is done, and set then, once.
8
+ // A key not kept yet is followed as the map is (a SvelteMap's reader of a key it doesn't hold
9
+ // reads again as any key is added): a size never kept, mid-drag as another key is kept for the
10
+ // first time, goes back to its own. Svelte doesn't let a reaction follow state it made itself,
11
+ // so a cell of a key's own, made as the key is first read, would never be followed.
12
+
13
+ import { SvelteMap } from 'svelte/reactivity';
14
+
15
+ /**
16
+ * A host's settings, by key: what `get` reads in a reaction (a $derived, an effect, the markup)
17
+ * it reads again when `set` changes it. A host's own are built on `memorySettings`.
18
+ * @typedef {object} Settings
19
+ * @property {(key: string) => unknown} get the value kept for `key`; undefined for none
20
+ * @property {(key: string, value: unknown) => void} set keeps `value` for `key`
21
+ */
22
+
23
+ /**
24
+ * Settings kept in memory alone, from `values`: gone with the page.
25
+ * @param {Record<string, unknown>} [values]
26
+ * @returns {Settings}
27
+ */
28
+ export function memorySettings(values = {}) {
29
+ const kept = new SvelteMap(Object.entries(values));
30
+ return { get: (key) => kept.get(key), set: (key, value) => void kept.set(key, value) };
31
+ }
32
+
33
+ /**
34
+ * Settings kept in `storage` (the page's localStorage) as one JSON object under `name`. A page
35
+ * that may not keep anything there (a private window's quota, a policy), or finds no JSON
36
+ * object under the name, keeps them in memory for the page's life.
37
+ * @param {string} [name]
38
+ * @param {Pick<Storage, 'getItem' | 'setItem'> | undefined} [storage]
39
+ * @returns {Settings}
40
+ */
41
+ export function localSettings(name = 'kupcad.settings', storage = globalThis.localStorage) {
42
+ const settings = memorySettings(read(storage, name));
43
+ return {
44
+ get: settings.get,
45
+ set(key, value) {
46
+ settings.set(key, value);
47
+ try {
48
+ const all = { ...read(storage, name), [key]: value };
49
+ storage?.setItem(name, JSON.stringify(all));
50
+ } catch {
51
+ // Kept in memory: the page may not store anything
52
+ }
53
+ },
54
+ };
55
+ }
56
+
57
+ /**
58
+ * The JSON object `storage` keeps under `name`; an empty one for none, or for what isn't one.
59
+ * @param {Pick<Storage, 'getItem'> | undefined} storage
60
+ * @param {string} name
61
+ * @returns {Record<string, unknown>}
62
+ */
63
+ function read(storage, name) {
64
+ try {
65
+ const parsed = JSON.parse(storage?.getItem(name) ?? '{}');
66
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ /**
73
+ * A pane's size kept under `key`: a finite number of pixels, else `fallback` (none kept, or
74
+ * what was kept is no size).
75
+ * @param {Settings} settings
76
+ * @param {string} key
77
+ * @param {number} fallback
78
+ */
79
+ export function sizeSetting(settings, key, fallback) {
80
+ const kept = settings.get(key);
81
+ return typeof kept === 'number' && Number.isFinite(kept) && kept > 0 ? kept : fallback;
82
+ }
@@ -0,0 +1,59 @@
1
+ // The keys and the pointer's moves a person can't see on the screen, listed in ShortcutsDialog:
2
+ // the viewer's (VIEWER_SHORTCUTS), and a host's own groups before them. Named as the system
3
+ // names its keys: ⌘ and ⌥ on a Mac, Ctrl and Alt elsewhere.
4
+
5
+ /**
6
+ * A group of shortcuts under its title: each a key, or keys pressed together, and what it does.
7
+ * @typedef {object} ShortcutGroup
8
+ * @property {string} title
9
+ * @property {readonly (readonly [keys: string, what: string])[]} keys
10
+ * @property {string} [note] what a person needs to know first (where the keys work)
11
+ */
12
+
13
+ /** @typedef {'mac' | 'windows' | 'other'} KeyboardSystem */
14
+
15
+ /**
16
+ * The system the page runs on, as its keys are named.
17
+ * @param {string} [agent] the browser's user agent
18
+ * @returns {KeyboardSystem}
19
+ */
20
+ export function keyboardSystem(agent = globalThis.navigator?.userAgent ?? '') {
21
+ if (/Mac|iPhone|iPad/.test(agent)) return 'mac';
22
+ return agent.includes('Windows') ? 'windows' : 'other';
23
+ }
24
+
25
+ /**
26
+ * The viewer's shortcuts, its keys named as `system` names them. Cmd is the Meta key, which
27
+ * Windows calls Win and Linux Super.
28
+ * @param {KeyboardSystem} system
29
+ * @returns {ShortcutGroup}
30
+ */
31
+ export function viewerShortcuts(system) {
32
+ const alt = system === 'mac' ? '⌥' : 'Alt';
33
+ const meta = system === 'mac' ? '⌘' : system === 'windows' ? 'Win' : 'Super';
34
+ return {
35
+ title: 'Viewer',
36
+ note: 'Keys once the model has the focus: a click on it gives it.',
37
+ keys: [
38
+ ['0', 'Iso view'],
39
+ ['1', 'Top view'],
40
+ ['2', 'Bottom view'],
41
+ ['3', 'Front view'],
42
+ ['4', 'Back view'],
43
+ ['5', 'Left view'],
44
+ ['6', 'Right view'],
45
+ ['F', 'Fit the model'],
46
+ ['I', 'Zoom in'],
47
+ ['O', 'Zoom out'],
48
+ ['Drag', 'Turn the view'],
49
+ ['Right or middle drag', 'Pan'],
50
+ ['Wheel, pinch', 'Zoom'],
51
+ ['Shift + wheel', 'Pan'],
52
+ [`${alt} + wheel`, 'Move the section'],
53
+ [`${meta} + wheel`, 'Turn the view'],
54
+ ],
55
+ };
56
+ }
57
+
58
+ /** The viewer's shortcuts on the system the page runs on. */
59
+ export const VIEWER_SHORTCUTS = viewerShortcuts(keyboardSystem());