@weasel-js/labkit 1.4.4 → 1.5.1
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 +54 -6
- package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
- package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
- package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
- package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
- package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
- package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
- package/dist/canvas/index.d.ts +18 -129
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +5 -93
- package/dist/chrome/index.js +10 -6
- package/dist/chunk-7PFH3CNM.js +150 -0
- package/dist/chunk-7PFH3CNM.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
- package/dist/chunk-CEIUBPPW.js.map +1 -0
- package/dist/chunk-EH7UEQV7.js +590 -0
- package/dist/chunk-EH7UEQV7.js.map +1 -0
- package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
- package/dist/chunk-EHGVROVV.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
- package/dist/chunk-IHUVQ3TX.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
- package/dist/chunk-IJJUK4FU.js.map +1 -0
- package/dist/chunk-IXTQI7JX.js +737 -0
- package/dist/chunk-IXTQI7JX.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
- package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
- package/dist/chunk-PHK4W562.js +72 -0
- package/dist/chunk-PHK4W562.js.map +1 -0
- package/dist/chunk-PJ7WEQBJ.js +18 -0
- package/dist/chunk-PJ7WEQBJ.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-T3EC2DXD.js +12628 -0
- package/dist/chunk-T3EC2DXD.js.map +1 -0
- package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
- package/dist/chunk-UN4U4P63.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/chunk-Z76KEZAX.js +15 -0
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/chunk-ZDHY47EV.js +363 -0
- package/dist/chunk-ZDHY47EV.js.map +1 -0
- package/dist/config/index.d.ts +276 -0
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +3 -4
- package/dist/controls/index.js +4 -3
- package/dist/dragdrop/index.d.ts +3 -5
- package/dist/index.d.ts +266 -413
- package/dist/index.js +1775 -2251
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -6
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +69 -20
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +175 -309
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +55 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +13 -8
- package/dist/state/index.js +18 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +223 -76
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +3 -5
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +56 -5
- package/src/annotations/Annotations.less +4 -0
- package/src/annotations/Annotations.overlay.test.tsx +105 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +5 -4
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -0
- package/src/annotations/toolMap.test.ts +9 -5
- package/src/annotations/toolMap.ts +6 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.stories.tsx +1 -1
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.test.ts +28 -0
- package/src/canvas/usePanZoom.ts +21 -9
- package/src/chrome/ChromeRegions.stories.tsx +1 -4
- package/src/chrome/LabChrome.tsx +130 -0
- package/src/chrome/builtins.test.ts +1 -0
- package/src/chrome/builtins.tsx +21 -31
- package/src/chrome/index.ts +16 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +16 -9
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +34 -16
- package/src/chrome/types.ts +50 -20
- package/src/config/auto.test.ts +12 -0
- package/src/config/auto.ts +15 -0
- package/src/config/autoConfig.test.ts +107 -0
- package/src/config/autoConfig.ts +153 -0
- package/src/config/builder.test.ts +42 -0
- package/src/config/builder.ts +68 -17
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +16 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/path.ts +7 -2
- package/src/config/resolve.test.ts +25 -0
- package/src/config/resolve.ts +17 -1
- package/src/config/types.ts +34 -2
- package/src/config/useResolvedConfig.ts +26 -0
- package/src/controls/AutoControls.stories.tsx +88 -0
- package/src/controls/ControlPanel.stories.tsx +42 -1
- package/src/controls/ControlPanel.test.tsx +530 -4
- package/src/controls/ControlPanel.tsx +562 -82
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +66 -5
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +40 -1
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.chrome.test.tsx +16 -0
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +1 -3
- package/src/lab/Lab.surface.test.tsx +71 -3
- package/src/lab/Lab.test.tsx +168 -16
- package/src/lab/Lab.tsx +434 -129
- package/src/lab/LabContext.ts +8 -0
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +1 -4
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -14
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabRoot.test.tsx +56 -0
- package/src/lab/LabRoot.tsx +47 -0
- package/src/lab/LabShell.less +73 -15
- package/src/lab/LabShell.stories.tsx +50 -1
- package/src/lab/LabShell.tsx +18 -27
- package/src/lab/LabSwitcher.less +18 -11
- package/src/lab/LabSwitcher.test.tsx +14 -9
- package/src/lab/LabSwitcher.tsx +8 -4
- package/src/lab/Workspace.less +1 -1
- package/src/lab/Workspace.stories.tsx +1 -1
- package/src/lab/Workspace.surface.test.tsx +1 -0
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/layers/LayerList.stories.tsx +1 -1
- package/src/loupe/AGENTS.md +17 -5
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/LoupeGestures.tsx +67 -0
- package/src/loupe/TrialLoupe.tsx +43 -28
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/index.ts +4 -0
- package/src/loupe/loupeActions.ts +98 -0
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +60 -3
- package/src/loupe/useLoupe.ts +29 -49
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +14 -2
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/FpsMeter.stories.tsx +1 -1
- package/src/primitives/JobProgress.stories.tsx +1 -1
- package/src/primitives/Legend.stories.tsx +1 -1
- package/src/primitives/ScaleIndicator.stories.tsx +1 -1
- package/src/primitives/Sidebar.less +7 -0
- package/src/primitives/Sidebar.stories.tsx +1 -1
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +133 -0
- package/src/primitives/Split.tsx +175 -0
- package/src/primitives/StatusBar.stories.tsx +1 -1
- package/src/primitives/Toolbar.stories.tsx +1 -1
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/specimen/Specimen.less +95 -0
- package/src/specimen/Specimen.stories.tsx +13 -0
- package/src/specimen/Specimen.test.tsx +13 -0
- package/src/specimen/Specimen.tsx +888 -0
- package/src/state/Persistence.stories.tsx +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +1 -1
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +169 -635
- package/src/state/store.ts +146 -163
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +65 -17
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +87 -3
- package/src/state/useTrialState.ts +9 -1
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +3 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +1 -0
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +231 -1
- package/src/surface/useTiledSurface.ts +76 -15
- package/src/theme/Interstellar.stories.tsx +9 -9
- package/src/theme/interstellar.test.ts +17 -6
- package/src/theme/interstellar.theme.json +180 -0
- package/src/theme/interstellar.ts +8 -5
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +59 -7
- package/src/trial/Trial.canvas.test.tsx +32 -0
- package/src/trial/Trial.config.test.tsx +75 -2
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +5 -26
- package/src/trial/Trial.stories.tsx +5 -3
- package/src/trial/Trial.test.tsx +31 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +139 -52
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +24 -7
- package/src/trial/trialOps.test.ts +101 -1
- package/src/trial/trialOps.ts +67 -7
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2P6PP5N4.js +0 -553
- package/dist/chunk-2P6PP5N4.js.map +0 -1
- package/dist/chunk-64ZCN3DA.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-E44SU6XS.js +0 -50
- package/dist/chunk-E44SU6XS.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MOM3GOVY.js.map +0 -1
- package/dist/chunk-SMHP6XZ4.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
- package/dist/chunk-U3IYHIAE.js +0 -343
- package/dist/chunk-U3IYHIAE.js.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
- package/src/theme/interstellar.tokens.json +0 -61
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
NumberRow,
|
|
7
7
|
type PrefGroup,
|
|
8
8
|
type PrefLeaf,
|
|
9
|
+
type PrefNumber,
|
|
10
|
+
type PrefNumberFormat,
|
|
11
|
+
type PrefNumberUnit,
|
|
9
12
|
type PropertyAlign,
|
|
10
13
|
type PropertyDensity,
|
|
11
14
|
PropertyGroup,
|
|
@@ -13,12 +16,18 @@ import {
|
|
|
13
16
|
type PropertyListPack,
|
|
14
17
|
PropertyRow,
|
|
15
18
|
type PropertyRowLayout,
|
|
19
|
+
prefDisplayBounds,
|
|
20
|
+
Select,
|
|
16
21
|
SelectRow,
|
|
17
22
|
SliderRow,
|
|
23
|
+
Switch,
|
|
24
|
+
SwitchRow,
|
|
18
25
|
TextRow,
|
|
19
26
|
ToggleRow,
|
|
20
27
|
} from '@weasel-js/ui';
|
|
21
28
|
import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
29
|
+
import { auto as autoValue } from '../config/auto';
|
|
30
|
+
import { resolveAutoConfig } from '../config/autoConfig';
|
|
22
31
|
import { fromConfigFields } from '../config/fromConfigField';
|
|
23
32
|
import { schemaNodeAtPath, valueAtPath } from '../config/path';
|
|
24
33
|
import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
|
|
@@ -26,15 +35,33 @@ import { isLeafVisible } from '../config/visible';
|
|
|
26
35
|
import type { ConfigField } from './types';
|
|
27
36
|
|
|
28
37
|
/** How a panel packs its rows into the two-column property grid.
|
|
29
|
-
* - `'auto'`: narrow controls (numbers, checkboxes, dropdowns,
|
|
38
|
+
* - `'auto'`: narrow controls (numbers, checkboxes, dropdowns, colors) sit
|
|
30
39
|
* two per row; the ones that need the width — text, sliders, segmented
|
|
31
40
|
* toggles — span it.
|
|
32
41
|
* - `'pairs'`: every row pairs. A control that needs the full width says so
|
|
33
42
|
* itself, with `<PropertyRow span>`.
|
|
34
|
-
* - `'one-up'`: one control per row,
|
|
43
|
+
* - `'one-up'`: one control per row, colors excepted.
|
|
35
44
|
*/
|
|
36
45
|
export type ControlPack = 'auto' | 'pairs' | 'one-up';
|
|
37
46
|
|
|
47
|
+
/** How the rows under one heading are laid out: the panel's, or a section's own. */
|
|
48
|
+
interface Rows {
|
|
49
|
+
pack: ControlPack;
|
|
50
|
+
layout: PropertyRowLayout | undefined;
|
|
51
|
+
grid: PropertyListPack;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const gridOf = (pack: ControlPack): PropertyListPack =>
|
|
55
|
+
pack === 'one-up' ? 'auto-color' : 'pairs';
|
|
56
|
+
|
|
57
|
+
function sectionRows(section: SectionSpec, rows: Rows): Rows {
|
|
58
|
+
return {
|
|
59
|
+
pack: section.pack ?? rows.pack,
|
|
60
|
+
layout: section.layout ?? rows.layout,
|
|
61
|
+
grid: section.pack ? gridOf(section.pack) : rows.grid,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
39
66
|
/** The instrument's resolved config schema. */
|
|
40
67
|
schema?: ResolvedConfig;
|
|
@@ -79,11 +106,19 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
|
79
106
|
/** A fold moved. The key is a section's label — prefixed by its group's
|
|
80
107
|
* dotted path when the section sits inside one — or a group's own path. */
|
|
81
108
|
onCollapse?: (key: string, collapsed: boolean) => void;
|
|
109
|
+
/** Dotted paths currently unpinned. A row in this set draws ghosted and its
|
|
110
|
+
* dot reads as auto, and the dot writes the sentinel back through
|
|
111
|
+
* `setConfig`. Omitted, the panel keeps the set itself: the dots still work
|
|
112
|
+
* and the sentinel never reaches `setConfig`, which would otherwise store it
|
|
113
|
+
* as the row's value. */
|
|
114
|
+
auto?: ReadonlySet<string>;
|
|
82
115
|
/** Draw leaves marked `hidden`. */
|
|
83
116
|
showHidden?: boolean;
|
|
84
117
|
className?: string;
|
|
85
118
|
}
|
|
86
119
|
|
|
120
|
+
const NO_AUTO: ReadonlySet<string> = new Set();
|
|
121
|
+
|
|
87
122
|
/** Render an instrument's config schema as a stack of controls, each writing
|
|
88
123
|
* back through `setConfig`. Built on the property rows, so a lab's controls
|
|
89
124
|
* are the same aligned, themed rows the rest of the kit uses. */
|
|
@@ -100,12 +135,64 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
100
135
|
collapse,
|
|
101
136
|
collapsed,
|
|
102
137
|
onCollapse,
|
|
138
|
+
auto: given,
|
|
103
139
|
showHidden = false,
|
|
104
140
|
className,
|
|
105
141
|
}: ControlPanelProps<TC>) {
|
|
106
142
|
const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
|
|
107
143
|
|
|
108
|
-
|
|
144
|
+
// An owner that tracks the set decides what a dot means; one that does not
|
|
145
|
+
// gets a panel that decides for itself, because the alternative is handing it
|
|
146
|
+
// a sentinel it would write into the config as though it were a value.
|
|
147
|
+
const [ownAuto, setOwnAuto] = useState<ReadonlySet<string>>(NO_AUTO);
|
|
148
|
+
const auto = given ?? ownAuto;
|
|
149
|
+
const setRowAuto = (path: string, next: boolean, value: unknown): void => {
|
|
150
|
+
if (given) {
|
|
151
|
+
setConfig(path, next ? autoValue : value);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
setOwnAuto((prev) => {
|
|
155
|
+
const now = new Set(prev);
|
|
156
|
+
if (next) now.add(path);
|
|
157
|
+
else now.delete(path);
|
|
158
|
+
return now;
|
|
159
|
+
});
|
|
160
|
+
// Pinning keeps whatever the row was showing, the same value the controlled
|
|
161
|
+
// path writes back.
|
|
162
|
+
if (!next) setConfig(path, value);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// What the instrument is reading. A row draws and reports from this rather
|
|
166
|
+
// than calling its own resolver against the raw config, which would disagree
|
|
167
|
+
// with the instrument whenever a resolver depends on another auto path.
|
|
168
|
+
const shown = useMemo(
|
|
169
|
+
() => (auto && auto.size > 0 ? resolveAutoConfig(resolved, config, auto) : config),
|
|
170
|
+
[resolved, config, auto],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
174
|
+
const toggles = useRef(new Map<string, () => void>());
|
|
175
|
+
// Capture phase, because `PropertyRow` is a <label>: a bubbled handler runs
|
|
176
|
+
// after the browser has already begun a range drag or a native control's
|
|
177
|
+
// activation, so the shift-click moves the very value it was meant to unpin.
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const host = listRef.current;
|
|
180
|
+
if (!host) return;
|
|
181
|
+
const onDown = (e: PointerEvent) => {
|
|
182
|
+
if (!e.shiftKey) return;
|
|
183
|
+
const row = (e.target as HTMLElement | null)?.closest('[data-auto-path]');
|
|
184
|
+
const path = row?.getAttribute('data-auto-path');
|
|
185
|
+
const toggle = path ? toggles.current.get(path) : undefined;
|
|
186
|
+
if (!toggle) return;
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
e.stopPropagation();
|
|
189
|
+
toggle();
|
|
190
|
+
};
|
|
191
|
+
host.addEventListener('pointerdown', onDown, true);
|
|
192
|
+
return () => host.removeEventListener('pointerdown', onDown, true);
|
|
193
|
+
}, []);
|
|
194
|
+
|
|
195
|
+
const gridPack = gridOf(pack);
|
|
109
196
|
// A section that declares how it opens is foldable whether or not the lab
|
|
110
197
|
// asked for folds — there is nothing else for the declaration to mean.
|
|
111
198
|
const folds =
|
|
@@ -115,8 +202,8 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
115
202
|
resolved.sections.some((s) => s.collapsed !== undefined);
|
|
116
203
|
const startsFolded = collapse === 'closed';
|
|
117
204
|
|
|
118
|
-
const fold = (key: string, declared?: boolean) => ({
|
|
119
|
-
pack:
|
|
205
|
+
const fold = (key: string, declared?: boolean, grid: PropertyListPack = gridPack) => ({
|
|
206
|
+
pack: grid,
|
|
120
207
|
collapsible: folds,
|
|
121
208
|
defaultCollapsed: declared ?? startsFolded,
|
|
122
209
|
collapsed: collapsed ? (collapsed[key] ?? declared ?? startsFolded) : undefined,
|
|
@@ -124,7 +211,7 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
124
211
|
});
|
|
125
212
|
|
|
126
213
|
/** One node, which is either a group to recurse into or a row to draw. */
|
|
127
|
-
const node = (path: string): ReactNode => {
|
|
214
|
+
const node = (path: string, rows: Rows): ReactNode => {
|
|
128
215
|
const found = schemaNodeAtPath(resolved.group, path);
|
|
129
216
|
if (!found) return null;
|
|
130
217
|
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
|
|
@@ -136,53 +223,117 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
136
223
|
leaf={found}
|
|
137
224
|
resolved={resolved}
|
|
138
225
|
config={config}
|
|
226
|
+
shown={shown}
|
|
139
227
|
setConfig={setConfig}
|
|
140
228
|
renderers={renderers}
|
|
141
|
-
pack={pack}
|
|
142
|
-
layout={layout}
|
|
229
|
+
pack={rows.pack}
|
|
230
|
+
layout={rows.layout}
|
|
231
|
+
auto={auto}
|
|
232
|
+
setRowAuto={setRowAuto}
|
|
233
|
+
toggles={toggles.current}
|
|
143
234
|
/>
|
|
144
235
|
);
|
|
145
236
|
}
|
|
146
237
|
// A group with no name organizes without heading it — core's rule for an
|
|
147
238
|
// empty `PrefGroup.name` — so it contributes its rows and no chrome.
|
|
148
|
-
if (found.name === '') return <Fragment key={path}>{body(found, path)}</Fragment>;
|
|
239
|
+
if (found.name === '') return <Fragment key={path}>{body(found, path, rows)}</Fragment>;
|
|
149
240
|
return (
|
|
150
|
-
<PropertyGroup key={path} title={found.name} {...fold(path)}>
|
|
151
|
-
{body(found, path)}
|
|
241
|
+
<PropertyGroup key={path} title={found.name} {...fold(path, undefined, rows.grid)}>
|
|
242
|
+
{body(found, path, rows)}
|
|
152
243
|
</PropertyGroup>
|
|
153
244
|
);
|
|
154
245
|
};
|
|
155
246
|
|
|
247
|
+
/**
|
|
248
|
+
* The leaf at `path` if it may share a row with its neighbours: `pair` is
|
|
249
|
+
* presentational, so a leaf whose control the lab draws itself, or one whose
|
|
250
|
+
* control needs the whole row, keeps its own row instead.
|
|
251
|
+
*/
|
|
252
|
+
const pairable = (path: string): { leaf: PrefLeaf; pair: string } | undefined => {
|
|
253
|
+
const found = schemaNodeAtPath(resolved.group, path);
|
|
254
|
+
if (!found || !isPrefLeaf(found)) return undefined;
|
|
255
|
+
const pair = extra<string>(found, 'pair');
|
|
256
|
+
if (pair === undefined) return undefined;
|
|
257
|
+
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden))
|
|
258
|
+
return undefined;
|
|
259
|
+
if (renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[found.kind]) return undefined;
|
|
260
|
+
if (!isBuiltinToolPref(found)) return undefined;
|
|
261
|
+
if (found.kind === 'paint' || found.kind === 'object' || isSliderLeaf(found)) return undefined;
|
|
262
|
+
return { leaf: found, pair };
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** A run of sibling paths as rows, merging each run of adjacent leaves
|
|
266
|
+
* sharing a `pair` id into one row named by the pair. */
|
|
267
|
+
const rowsFor = (paths: readonly string[], rows: Rows): ReactNode[] => {
|
|
268
|
+
const out: ReactNode[] = [];
|
|
269
|
+
for (let i = 0; i < paths.length; ) {
|
|
270
|
+
const head = pairable(paths[i]);
|
|
271
|
+
if (!head) {
|
|
272
|
+
out.push(node(paths[i], rows));
|
|
273
|
+
i += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const { pair } = head;
|
|
277
|
+
const cells = [{ path: paths[i], leaf: head.leaf }];
|
|
278
|
+
let j = i + 1;
|
|
279
|
+
for (; j < paths.length; j += 1) {
|
|
280
|
+
const next = pairable(paths[j]);
|
|
281
|
+
if (!next || next.pair !== pair) break;
|
|
282
|
+
cells.push({ path: paths[j], leaf: next.leaf });
|
|
283
|
+
}
|
|
284
|
+
out.push(
|
|
285
|
+
<PairedRow
|
|
286
|
+
key={paths[i]}
|
|
287
|
+
label={pair}
|
|
288
|
+
cells={cells}
|
|
289
|
+
shown={shown}
|
|
290
|
+
setConfig={setConfig}
|
|
291
|
+
layout={rows.layout}
|
|
292
|
+
/>,
|
|
293
|
+
);
|
|
294
|
+
i = j;
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
};
|
|
298
|
+
|
|
156
299
|
/** One group's children: its loose nodes, then its sections. */
|
|
157
|
-
const body = (group: PrefGroup, at: string): ReactNode => {
|
|
300
|
+
const body = (group: PrefGroup, at: string, rows: Rows): ReactNode => {
|
|
158
301
|
const sections = resolved.sections.filter((s) => s.at === at);
|
|
159
302
|
const sectioned = new Set(sections.flatMap((s) => s.paths));
|
|
160
303
|
const paths = Object.keys(group.children).map((key) => (at === '' ? key : `${at}.${key}`));
|
|
161
304
|
return (
|
|
162
305
|
<>
|
|
163
|
-
{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
306
|
+
{rowsFor(
|
|
307
|
+
paths.filter((p) => !sectioned.has(p)),
|
|
308
|
+
rows,
|
|
309
|
+
)}
|
|
310
|
+
{sections.map((section) => {
|
|
311
|
+
const inner = sectionRows(section, rows);
|
|
312
|
+
return (
|
|
313
|
+
<PropertyGroup
|
|
314
|
+
key={sectionKey(section)}
|
|
315
|
+
title={section.label}
|
|
316
|
+
{...fold(sectionKey(section), section.collapsed, inner.grid)}
|
|
317
|
+
>
|
|
318
|
+
{rowsFor(section.paths, inner)}
|
|
319
|
+
</PropertyGroup>
|
|
320
|
+
);
|
|
321
|
+
})}
|
|
173
322
|
</>
|
|
174
323
|
);
|
|
175
324
|
};
|
|
176
325
|
|
|
177
326
|
return (
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
327
|
+
<div ref={listRef}>
|
|
328
|
+
<PropertyList
|
|
329
|
+
pack={gridPack}
|
|
330
|
+
density={density}
|
|
331
|
+
align={align}
|
|
332
|
+
className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
|
|
333
|
+
>
|
|
334
|
+
{body(resolved.group, '', { pack, layout, grid: gridPack })}
|
|
335
|
+
</PropertyList>
|
|
336
|
+
</div>
|
|
186
337
|
);
|
|
187
338
|
}
|
|
188
339
|
|
|
@@ -198,10 +349,27 @@ interface ControlRowProps<TC extends Record<string, unknown>> {
|
|
|
198
349
|
leaf: PrefLeaf;
|
|
199
350
|
resolved: ResolvedConfig;
|
|
200
351
|
config: TC;
|
|
352
|
+
/** `config` with every auto path resolved -- what the instrument reads. */
|
|
353
|
+
shown: TC;
|
|
201
354
|
setConfig: (path: string, value: unknown) => void;
|
|
202
355
|
renderers?: Record<string, ControlRenderer>;
|
|
203
356
|
pack: ControlPack;
|
|
204
357
|
layout?: PropertyRowLayout;
|
|
358
|
+
auto?: ReadonlySet<string>;
|
|
359
|
+
setRowAuto: (path: string, next: boolean, value: unknown) => void;
|
|
360
|
+
toggles: Map<string, () => void>;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Whether this leaf draws as a slider, mirroring the condition the `number`
|
|
364
|
+
* arm below branches on. A slider is the one control whose value cannot be
|
|
365
|
+
* read off the control itself. */
|
|
366
|
+
function isSliderLeaf(leaf: PrefLeaf): boolean {
|
|
367
|
+
return (
|
|
368
|
+
leaf.kind === 'number' &&
|
|
369
|
+
extra<string>(leaf, 'control') === 'slider' &&
|
|
370
|
+
extra<number>(leaf, 'min') !== undefined &&
|
|
371
|
+
extra<number>(leaf, 'max') !== undefined
|
|
372
|
+
);
|
|
205
373
|
}
|
|
206
374
|
|
|
207
375
|
/** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
|
|
@@ -215,21 +383,62 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
215
383
|
leaf,
|
|
216
384
|
resolved,
|
|
217
385
|
config,
|
|
386
|
+
shown,
|
|
218
387
|
setConfig,
|
|
219
388
|
renderers,
|
|
220
389
|
pack,
|
|
221
390
|
layout,
|
|
391
|
+
auto,
|
|
392
|
+
setRowAuto,
|
|
393
|
+
toggles,
|
|
222
394
|
}: ControlRowProps<TC>) {
|
|
223
395
|
const write = (value: unknown): void => setConfig(path, value);
|
|
224
396
|
const fallback = extra<unknown>(leaf, 'default');
|
|
225
|
-
const
|
|
397
|
+
const pinned = valueAtPath(config, path) ?? fallback;
|
|
398
|
+
|
|
399
|
+
const isAutoRow = auto?.has(path) ?? false;
|
|
400
|
+
const canAuto = !extra<boolean>(leaf, 'manual');
|
|
401
|
+
// Read out of the resolved config rather than re-running this leaf's own
|
|
402
|
+
// resolver, so the row reports the value the instrument actually got — the
|
|
403
|
+
// two diverge as soon as a resolver reads another auto path.
|
|
404
|
+
const resolvedValue = isAutoRow ? valueAtPath(shown, path) : undefined;
|
|
405
|
+
// An auto row draws what the resolver decided, not the value underneath it —
|
|
406
|
+
// a handle sitting at the pinned number while the readout says something else
|
|
407
|
+
// reads as a rendering bug. Pinning then keeps what you were looking at.
|
|
408
|
+
const value = resolvedValue ?? pinned;
|
|
409
|
+
const setAuto = (next: boolean): void => setRowAuto(path, next, value);
|
|
410
|
+
const onAutoChange = canAuto ? setAuto : undefined;
|
|
411
|
+
// A slider's handle is a position, not a number, so the readout is the only
|
|
412
|
+
// place its resolved value can be read. Every other control renders its own
|
|
413
|
+
// value, and repeating it there says it twice and wraps the narrow slot.
|
|
414
|
+
const readsItsOwnValue = !isSliderLeaf(leaf);
|
|
415
|
+
// A readout says what the control would say, so it reads in the display
|
|
416
|
+
// unit: a radian in a row that edits degrees is a different number.
|
|
417
|
+
const readoutValue =
|
|
418
|
+
leaf.kind === 'number' && typeof resolvedValue === 'number'
|
|
419
|
+
? numberField(leaf, resolvedValue).shown
|
|
420
|
+
: resolvedValue;
|
|
421
|
+
const autoReadout = !isAutoRow
|
|
422
|
+
? undefined
|
|
423
|
+
: resolvedValue === undefined || readsItsOwnValue
|
|
424
|
+
? 'auto'
|
|
425
|
+
: `auto · ${String(readoutValue)}`;
|
|
426
|
+
const autoProps = { auto: isAutoRow, onAutoChange, 'data-auto-path': canAuto ? path : undefined };
|
|
427
|
+
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
if (!canAuto) return;
|
|
430
|
+
toggles.set(path, () => setAuto(!isAutoRow));
|
|
431
|
+
return () => {
|
|
432
|
+
toggles.delete(path);
|
|
433
|
+
};
|
|
434
|
+
});
|
|
226
435
|
|
|
227
436
|
// Most specific wins, and within a tier the lab's entry beats the
|
|
228
437
|
// instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
|
|
229
438
|
const custom = renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[leaf.kind];
|
|
230
439
|
// A custom row places itself like any other; one that needs the full width
|
|
231
440
|
// says so with `<PropertyRow span>`, which is the same opt-out a built-in has.
|
|
232
|
-
if (custom) return custom({ path, pref: leaf, value, setValue: write });
|
|
441
|
+
if (custom) return custom({ path, pref: leaf, value, setValue: write, auto: isAutoRow, setAuto });
|
|
233
442
|
|
|
234
443
|
const label = leaf.name;
|
|
235
444
|
const description = leaf.description;
|
|
@@ -243,55 +452,61 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
243
452
|
|
|
244
453
|
switch (leaf.kind) {
|
|
245
454
|
case 'number': {
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
|
|
455
|
+
const field = numberField(leaf, read<number>());
|
|
456
|
+
const suffix = extra<string>(leaf, 'suffix') ?? field.unit?.suffix;
|
|
457
|
+
const notation = extra<PrefNumberFormat>(leaf, 'format');
|
|
458
|
+
if (isSliderLeaf(leaf)) {
|
|
251
459
|
return (
|
|
252
460
|
<SliderRow
|
|
253
461
|
label={label}
|
|
254
|
-
value={
|
|
255
|
-
min={min}
|
|
256
|
-
max={max}
|
|
257
|
-
step={step}
|
|
462
|
+
value={field.shown}
|
|
463
|
+
min={field.min as number}
|
|
464
|
+
max={field.max as number}
|
|
465
|
+
step={field.step}
|
|
466
|
+
notation={notation}
|
|
258
467
|
unit={suffix}
|
|
259
|
-
onChange={write}
|
|
468
|
+
onChange={(n) => write(field.store(n))}
|
|
469
|
+
readout={autoReadout}
|
|
260
470
|
layout={layout}
|
|
261
471
|
span={wide}
|
|
262
472
|
description={description}
|
|
473
|
+
{...autoProps}
|
|
263
474
|
/>
|
|
264
475
|
);
|
|
265
476
|
}
|
|
266
477
|
// NumberRow commits every keystroke and does not clamp, so the bounds a
|
|
267
478
|
// schema declares are enforced here — an instrument should never be
|
|
268
479
|
// handed a config value outside the range it asked for.
|
|
269
|
-
const lo = min ?? Number.NEGATIVE_INFINITY;
|
|
270
|
-
const hi = max ?? Number.POSITIVE_INFINITY;
|
|
271
480
|
return (
|
|
272
481
|
<NumberRow
|
|
273
482
|
label={label}
|
|
274
|
-
value={
|
|
275
|
-
min={min}
|
|
276
|
-
max={max}
|
|
277
|
-
step={step}
|
|
483
|
+
value={field.shown}
|
|
484
|
+
min={field.min}
|
|
485
|
+
max={field.max}
|
|
486
|
+
step={field.step}
|
|
278
487
|
unit={suffix}
|
|
279
|
-
onChange={(n) => write(
|
|
488
|
+
onChange={(n) => write(field.store(n))}
|
|
489
|
+
readout={autoReadout}
|
|
280
490
|
layout={layout}
|
|
281
491
|
description={description}
|
|
492
|
+
{...autoProps}
|
|
282
493
|
/>
|
|
283
494
|
);
|
|
284
495
|
}
|
|
285
|
-
case 'boolean':
|
|
496
|
+
case 'boolean': {
|
|
497
|
+
const Row = extra<string>(leaf, 'control') === 'switch' ? SwitchRow : CheckboxRow;
|
|
286
498
|
return (
|
|
287
|
-
<
|
|
499
|
+
<Row
|
|
288
500
|
label={label}
|
|
289
501
|
value={read<boolean>()}
|
|
290
502
|
onChange={write}
|
|
503
|
+
readout={autoReadout}
|
|
291
504
|
layout={layout}
|
|
292
505
|
description={description}
|
|
506
|
+
{...autoProps}
|
|
293
507
|
/>
|
|
294
508
|
);
|
|
509
|
+
}
|
|
295
510
|
case 'enum': {
|
|
296
511
|
const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
|
|
297
512
|
const segmented = extra<string>(leaf, 'control') === 'radio';
|
|
@@ -302,9 +517,11 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
302
517
|
value={read<string>()}
|
|
303
518
|
options={options}
|
|
304
519
|
onChange={write}
|
|
520
|
+
readout={autoReadout}
|
|
305
521
|
layout={layout}
|
|
306
522
|
span={segmented && wide}
|
|
307
523
|
description={description}
|
|
524
|
+
{...autoProps}
|
|
308
525
|
/>
|
|
309
526
|
);
|
|
310
527
|
}
|
|
@@ -315,21 +532,38 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
315
532
|
label={label}
|
|
316
533
|
value={read<string>()}
|
|
317
534
|
write={write}
|
|
535
|
+
readout={autoReadout}
|
|
318
536
|
layout={layout}
|
|
319
537
|
span={wide}
|
|
320
538
|
description={description}
|
|
539
|
+
{...autoProps}
|
|
321
540
|
/>
|
|
322
541
|
);
|
|
323
|
-
case 'color':
|
|
542
|
+
case 'color': {
|
|
543
|
+
// `ColorRow` takes alpha as a number beside a `#rrggbb` swatch, so an
|
|
544
|
+
// `#rrggbbaa` value has to be split going in and rejoined coming out —
|
|
545
|
+
// handed whole to the swatch, the browser cannot parse it and the first
|
|
546
|
+
// edit writes back black.
|
|
547
|
+
const common = {
|
|
548
|
+
label,
|
|
549
|
+
readout: autoReadout,
|
|
550
|
+
layout,
|
|
551
|
+
description,
|
|
552
|
+
...autoProps,
|
|
553
|
+
};
|
|
554
|
+
if (extra<boolean>(leaf, 'alpha') !== true)
|
|
555
|
+
return <ColorRow {...common} value={read<string>()} onChange={write} />;
|
|
556
|
+
const { rgb, alpha } = splitHexAlpha(read<string>());
|
|
324
557
|
return (
|
|
325
558
|
<ColorRow
|
|
326
|
-
|
|
327
|
-
value={
|
|
328
|
-
onChange={write}
|
|
329
|
-
|
|
330
|
-
|
|
559
|
+
{...common}
|
|
560
|
+
value={rgb}
|
|
561
|
+
onChange={(next) => write(joinHexAlpha(next, alpha))}
|
|
562
|
+
alpha={alpha}
|
|
563
|
+
onAlphaChange={(next) => write(joinHexAlpha(rgb, next))}
|
|
331
564
|
/>
|
|
332
565
|
);
|
|
566
|
+
}
|
|
333
567
|
case 'paint':
|
|
334
568
|
case 'object':
|
|
335
569
|
// Declined: a hex swatch would write a solid over a gradient, and a flat
|
|
@@ -365,9 +599,7 @@ function UnwiredRow({
|
|
|
365
599
|
);
|
|
366
600
|
}
|
|
367
601
|
|
|
368
|
-
/**
|
|
369
|
-
* keystroke, which means the row is locally controlled between commits and has
|
|
370
|
-
* to notice when the config changes underneath it. */
|
|
602
|
+
/** A text row whose writes are debounced — see {@link useDebouncedText}. */
|
|
371
603
|
function DebouncedTextRow({
|
|
372
604
|
leaf,
|
|
373
605
|
label,
|
|
@@ -376,6 +608,10 @@ function DebouncedTextRow({
|
|
|
376
608
|
layout,
|
|
377
609
|
span,
|
|
378
610
|
description,
|
|
611
|
+
readout,
|
|
612
|
+
auto,
|
|
613
|
+
onAutoChange,
|
|
614
|
+
'data-auto-path': autoPath,
|
|
379
615
|
}: {
|
|
380
616
|
leaf: PrefLeaf;
|
|
381
617
|
label: string;
|
|
@@ -384,8 +620,260 @@ function DebouncedTextRow({
|
|
|
384
620
|
layout?: PropertyRowLayout;
|
|
385
621
|
span?: boolean;
|
|
386
622
|
description?: string;
|
|
623
|
+
readout?: ReactNode;
|
|
624
|
+
auto?: boolean;
|
|
625
|
+
onAutoChange?: (next: boolean) => void;
|
|
626
|
+
'data-auto-path'?: string;
|
|
627
|
+
}) {
|
|
628
|
+
const text = useDebouncedText(
|
|
629
|
+
value,
|
|
630
|
+
write as (v: string) => void,
|
|
631
|
+
extra<number>(leaf, 'debounceMs') ?? 150,
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
return (
|
|
635
|
+
<TextRow
|
|
636
|
+
label={label}
|
|
637
|
+
readout={readout}
|
|
638
|
+
layout={layout}
|
|
639
|
+
span={span}
|
|
640
|
+
description={description}
|
|
641
|
+
auto={auto}
|
|
642
|
+
onAutoChange={onAutoChange}
|
|
643
|
+
data-auto-path={autoPath}
|
|
644
|
+
value={text.local}
|
|
645
|
+
placeholder={extra<string>(leaf, 'placeholder')}
|
|
646
|
+
maxLength={extra<number>(leaf, 'maxLength')}
|
|
647
|
+
onChange={text.type}
|
|
648
|
+
/>
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* A number leaf's field in the unit it is edited in: what the control shows,
|
|
654
|
+
* the bounds it shows them against, and what a typed number stores once it is
|
|
655
|
+
* clamped and converted back. Without a `unit` this is what the leaf declares,
|
|
656
|
+
* unchanged.
|
|
657
|
+
*/
|
|
658
|
+
function numberField(
|
|
659
|
+
leaf: PrefLeaf,
|
|
660
|
+
value: number,
|
|
661
|
+
): {
|
|
662
|
+
unit: PrefNumberUnit | undefined;
|
|
663
|
+
min: number | undefined;
|
|
664
|
+
max: number | undefined;
|
|
665
|
+
step: number | undefined;
|
|
666
|
+
shown: number;
|
|
667
|
+
store: (shown: number) => number;
|
|
668
|
+
} {
|
|
669
|
+
const unit = extra<PrefNumberUnit>(leaf, 'unit');
|
|
670
|
+
// `min`, `max` and `step` are declared in the stored unit alongside the
|
|
671
|
+
// value, so they convert with it — a leaf storing radians and showing
|
|
672
|
+
// degrees would otherwise clamp typed degrees against 0..6.28.
|
|
673
|
+
const bounds = unit
|
|
674
|
+
? prefDisplayBounds(leaf as PrefNumber)
|
|
675
|
+
: {
|
|
676
|
+
min: extra<number>(leaf, 'min'),
|
|
677
|
+
max: extra<number>(leaf, 'max'),
|
|
678
|
+
step: extra<number>(leaf, 'step'),
|
|
679
|
+
};
|
|
680
|
+
const lo = bounds.min ?? Number.NEGATIVE_INFINITY;
|
|
681
|
+
const hi = bounds.max ?? Number.POSITIVE_INFINITY;
|
|
682
|
+
return {
|
|
683
|
+
unit,
|
|
684
|
+
min: bounds.min,
|
|
685
|
+
max: bounds.max,
|
|
686
|
+
step: bounds.step,
|
|
687
|
+
shown: unit ? unit.toDisplay(value) : value,
|
|
688
|
+
store: (shown) => {
|
|
689
|
+
const clamped = Math.min(hi, Math.max(lo, shown));
|
|
690
|
+
return unit ? unit.fromDisplay(clamped) : clamped;
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` as the `#rrggbb` an
|
|
696
|
+
* `<input type="color">` can hold and the 0..1 alpha beside it. Anything else
|
|
697
|
+
* reads as opaque black, which is what the input would show for it anyway. */
|
|
698
|
+
function splitHexAlpha(value: string): { rgb: string; alpha: number } {
|
|
699
|
+
const digits = /^#([0-9a-f]{3,8})$/i.exec(value?.trim() ?? '')?.[1];
|
|
700
|
+
const twice = (s: string): string =>
|
|
701
|
+
s
|
|
702
|
+
.split('')
|
|
703
|
+
.map((c) => c + c)
|
|
704
|
+
.join('');
|
|
705
|
+
const byte = (s: string): number => Number.parseInt(s, 16) / 255;
|
|
706
|
+
switch (digits?.length) {
|
|
707
|
+
case 3:
|
|
708
|
+
return { rgb: `#${twice(digits)}`, alpha: 1 };
|
|
709
|
+
case 4:
|
|
710
|
+
return { rgb: `#${twice(digits.slice(0, 3))}`, alpha: byte(twice(digits.slice(3))) };
|
|
711
|
+
case 6:
|
|
712
|
+
return { rgb: `#${digits}`, alpha: 1 };
|
|
713
|
+
case 8:
|
|
714
|
+
return { rgb: `#${digits.slice(0, 6)}`, alpha: byte(digits.slice(6)) };
|
|
715
|
+
default:
|
|
716
|
+
return { rgb: '#000000', alpha: 1 };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** A `#rrggbb` and a 0..1 alpha back into the `#rrggbbaa` the config holds. */
|
|
721
|
+
function joinHexAlpha(rgb: string, alpha: number): string {
|
|
722
|
+
const byte = Math.max(0, Math.min(255, Math.round(alpha * 255)));
|
|
723
|
+
return `${rgb}${byte.toString(16).padStart(2, '0')}`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** One cell of a paired row, with its path and the leaf it draws. */
|
|
727
|
+
interface PairCellSpec {
|
|
728
|
+
path: string;
|
|
729
|
+
leaf: PrefLeaf;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Leaves sharing a `pair` id, side by side on one row the pair names — the
|
|
734
|
+
* compact `X` / `Y` idiom `SelectionPanel` draws for the same annotation.
|
|
735
|
+
*
|
|
736
|
+
* The cells are bare controls: a row holds one label column, so each leaf's
|
|
737
|
+
* own name stays with its control as the accessible name and its description
|
|
738
|
+
* as a title. For the same reason the row carries no pin dot — a dot names one
|
|
739
|
+
* path, and this row has several.
|
|
740
|
+
*/
|
|
741
|
+
function PairedRow<TC extends Record<string, unknown>>({
|
|
742
|
+
label,
|
|
743
|
+
cells,
|
|
744
|
+
shown,
|
|
745
|
+
setConfig,
|
|
746
|
+
layout,
|
|
747
|
+
}: {
|
|
748
|
+
label: string;
|
|
749
|
+
cells: readonly PairCellSpec[];
|
|
750
|
+
shown: TC;
|
|
751
|
+
setConfig: (path: string, value: unknown) => void;
|
|
752
|
+
layout?: PropertyRowLayout;
|
|
387
753
|
}) {
|
|
388
|
-
|
|
754
|
+
return (
|
|
755
|
+
<PropertyRow group label={label} layout={layout}>
|
|
756
|
+
{cells.map(({ path, leaf }) => (
|
|
757
|
+
<PairCell
|
|
758
|
+
key={path}
|
|
759
|
+
leaf={leaf}
|
|
760
|
+
value={valueAtPath(shown, path) ?? extra<unknown>(leaf, 'default')}
|
|
761
|
+
write={(value) => setConfig(path, value)}
|
|
762
|
+
/>
|
|
763
|
+
))}
|
|
764
|
+
</PropertyRow>
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** The control a paired cell holds, without the row chrome a whole row of its
|
|
769
|
+
* own would bring. */
|
|
770
|
+
function PairCell({
|
|
771
|
+
leaf,
|
|
772
|
+
value,
|
|
773
|
+
write,
|
|
774
|
+
}: {
|
|
775
|
+
leaf: PrefLeaf;
|
|
776
|
+
value: unknown;
|
|
777
|
+
write: (value: unknown) => void;
|
|
778
|
+
}) {
|
|
779
|
+
const name = leaf.name;
|
|
780
|
+
const title = leaf.description;
|
|
781
|
+
const text = useDebouncedText(
|
|
782
|
+
typeof value === 'string' ? value : '',
|
|
783
|
+
write,
|
|
784
|
+
extra<number>(leaf, 'debounceMs') ?? 150,
|
|
785
|
+
);
|
|
786
|
+
switch (leaf.kind) {
|
|
787
|
+
case 'number': {
|
|
788
|
+
const field = numberField(leaf, typeof value === 'number' ? value : 0);
|
|
789
|
+
return (
|
|
790
|
+
<input
|
|
791
|
+
type="number"
|
|
792
|
+
aria-label={name}
|
|
793
|
+
title={title}
|
|
794
|
+
value={field.shown}
|
|
795
|
+
min={field.min}
|
|
796
|
+
max={field.max}
|
|
797
|
+
step={field.step}
|
|
798
|
+
onChange={(e) => {
|
|
799
|
+
const n = Number(e.target.value);
|
|
800
|
+
if (e.target.value !== '' && Number.isFinite(n)) write(field.store(n));
|
|
801
|
+
}}
|
|
802
|
+
/>
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
case 'boolean':
|
|
806
|
+
return extra<string>(leaf, 'control') === 'switch' ? (
|
|
807
|
+
<Switch aria-label={name} isSelected={value === true} onChange={write} />
|
|
808
|
+
) : (
|
|
809
|
+
<input
|
|
810
|
+
type="checkbox"
|
|
811
|
+
aria-label={name}
|
|
812
|
+
title={title}
|
|
813
|
+
checked={value === true}
|
|
814
|
+
onChange={(e) => write(e.target.checked)}
|
|
815
|
+
/>
|
|
816
|
+
);
|
|
817
|
+
case 'string':
|
|
818
|
+
return (
|
|
819
|
+
<input
|
|
820
|
+
type="text"
|
|
821
|
+
aria-label={name}
|
|
822
|
+
title={title}
|
|
823
|
+
value={text.local}
|
|
824
|
+
placeholder={extra<string>(leaf, 'placeholder')}
|
|
825
|
+
maxLength={extra<number>(leaf, 'maxLength')}
|
|
826
|
+
onChange={(e) => text.type(e.target.value)}
|
|
827
|
+
/>
|
|
828
|
+
);
|
|
829
|
+
case 'color': {
|
|
830
|
+
// The alpha track needs a row of its own to sit under, so a paired
|
|
831
|
+
// swatch edits the color and carries the stored alpha through untouched.
|
|
832
|
+
const { rgb, alpha } = splitHexAlpha(typeof value === 'string' ? value : '');
|
|
833
|
+
const carriesAlpha = extra<boolean>(leaf, 'alpha') === true;
|
|
834
|
+
return (
|
|
835
|
+
<input
|
|
836
|
+
type="color"
|
|
837
|
+
aria-label={name}
|
|
838
|
+
title={title}
|
|
839
|
+
value={rgb}
|
|
840
|
+
onChange={(e) =>
|
|
841
|
+
write(carriesAlpha ? joinHexAlpha(e.target.value, alpha) : e.target.value)
|
|
842
|
+
}
|
|
843
|
+
/>
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
case 'enum': {
|
|
847
|
+
const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
|
|
848
|
+
// `Select` takes no `title`, so the cell's help hangs off a wrapper.
|
|
849
|
+
return (
|
|
850
|
+
<span title={title}>
|
|
851
|
+
<Select<string>
|
|
852
|
+
variant="bare"
|
|
853
|
+
aria-label={name}
|
|
854
|
+
options={options}
|
|
855
|
+
selectedKey={typeof value === 'string' ? value : null}
|
|
856
|
+
onSelectionChange={write}
|
|
857
|
+
/>
|
|
858
|
+
</span>
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
default:
|
|
862
|
+
// Unreachable: `pairable` admits only the kinds above.
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Live text held locally between debounced commits, so typing does not re-run
|
|
869
|
+
* the instrument on every keystroke. Locally controlled between commits, which
|
|
870
|
+
* means it has to notice the value changing underneath it.
|
|
871
|
+
*/
|
|
872
|
+
function useDebouncedText(
|
|
873
|
+
value: string,
|
|
874
|
+
write: (value: string) => void,
|
|
875
|
+
debounceMs: number,
|
|
876
|
+
): { local: string; type: (next: string) => void } {
|
|
389
877
|
const [local, setLocal] = useState(value);
|
|
390
878
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
391
879
|
const lastExternal = useRef(value);
|
|
@@ -404,25 +892,17 @@ function DebouncedTextRow({
|
|
|
404
892
|
[],
|
|
405
893
|
);
|
|
406
894
|
|
|
407
|
-
return
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
lastExternal.current = next;
|
|
421
|
-
write(next);
|
|
422
|
-
};
|
|
423
|
-
if (debounceMs === 0) commit();
|
|
424
|
-
else timer.current = setTimeout(commit, debounceMs);
|
|
425
|
-
}}
|
|
426
|
-
/>
|
|
427
|
-
);
|
|
895
|
+
return {
|
|
896
|
+
local,
|
|
897
|
+
type: (next) => {
|
|
898
|
+
setLocal(next);
|
|
899
|
+
if (timer.current) clearTimeout(timer.current);
|
|
900
|
+
const commit = () => {
|
|
901
|
+
lastExternal.current = next;
|
|
902
|
+
write(next);
|
|
903
|
+
};
|
|
904
|
+
if (debounceMs === 0) commit();
|
|
905
|
+
else timer.current = setTimeout(commit, debounceMs);
|
|
906
|
+
},
|
|
907
|
+
};
|
|
428
908
|
}
|