Tier 2: drag feel, wheel overhaul, refs/meta/forms, Meter, gradients
- rotaryMode: 'absolute' | 'relative' (angle deltas, no grab jump) |
'pickup' (MIDI-style: engages when the pointer sweeps past the value)
- dragAcceleration: velocity gain for relative drags (Shift bypasses);
hideCursorOnDrag hides the cursor mid-gesture
- Wheel: trackpad delta accumulation (flicks can't rocket the value,
one notch per wheel click), wheelStep override, wheelRequiresFocus
opt-out of the hover scroll-trap
- onChange/onChangeStart/onChangeEnd now receive { source: 'drag' |
'wheel' | 'keyboard' | 'reset' | 'api' }
- Every component forwards a ref to its root; name prop renders a
hidden form input; SVG primitives carry data-part attributes
- Arc gradient: position-anchored color stops rendered as sliced arcs
(mixColors/sampleGradient exported, unit-tested)
- New read-only Meter: LED level meter with peak hold, zones, readout
- Fader capColor/capLength for custom handle styling
- Docs: Meter/gradient/drag-feel gallery cards, white-cap fader demo,
API rows, README
This commit is contained in:
parent
804a85cca6
commit
0aee48b749
27 changed files with 756 additions and 134 deletions
|
|
@ -53,13 +53,19 @@ export const ApiDocs: React.FC = () => (
|
||||||
<Row name="origin" type="number" desc="Bipolar anchor: arcs and fader fills draw from this value instead of the minimum (origin={0} for pan/gain)." />
|
<Row name="origin" type="number" desc="Bipolar anchor: arcs and fader fills draw from this value instead of the minimum (origin={0} for pan/gain)." />
|
||||||
<Row name="detents / detentSize" type="number[] / number" def="— / 0.025" desc="Magnetic snap points: drags stick to these values within the capture radius. Wheel/keyboard unaffected." />
|
<Row name="detents / detentSize" type="number[] / number" def="— / 0.025" desc="Magnetic snap points: drags stick to these values within the capture radius. Wheel/keyboard unaffected." />
|
||||||
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
|
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
|
||||||
|
<Row name="rotaryMode" type="RotaryMode" def="'absolute'" desc="'absolute' tracks the pointer angle; 'relative' applies angle deltas (no grab jump); 'pickup' engages once the pointer sweeps past the value (MIDI pickup)." />
|
||||||
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
|
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
|
||||||
|
<Row name="dragAcceleration" type="number" def="0" desc="Velocity gain for relative drags: fast flicks cover more range, slow drags stay precise. Shift bypasses it." />
|
||||||
|
<Row name="hideCursorOnDrag" type="boolean" def="false" desc="Hide the mouse cursor for the duration of a drag." />
|
||||||
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during drags and wheel." />
|
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during drags and wheel." />
|
||||||
<Row name="angleOffset / angleRange" type="number" def="225 / 270" desc="Where travel starts and how far it sweeps, in degrees clockwise from 12 o'clock." />
|
<Row name="angleOffset / angleRange" type="number" def="225 / 270" desc="Where travel starts and how far it sweeps, in degrees clockwise from 12 o'clock." />
|
||||||
<Row name="enableWheel" type="boolean" def="true" desc="Scroll-wheel nudging (one step per notch)." />
|
<Row name="enableWheel" type="boolean" def="true" desc="Scroll-wheel nudging. Trackpad deltas accumulate into notches so a flick can't rocket the value." />
|
||||||
|
<Row name="wheelStep" type="number" def="step" desc="Value change per wheel notch, independent of the snapping step." />
|
||||||
|
<Row name="wheelRequiresFocus" type="boolean" def="false" desc="Only respond to the wheel while focused — hovering no longer captures page scroll." />
|
||||||
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
|
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
|
||||||
<Row name="onChange" type="(v: number) => void" desc="Fires for every distinct snapped value." />
|
<Row name="onChange" type="(v, meta) => void" desc="Fires for every distinct snapped value. meta.source tells you what caused it: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'." />
|
||||||
<Row name="onChangeStart / onChangeEnd" type="(v: number) => void" desc="Gesture boundaries — ideal for undo history or parameter automation." />
|
<Row name="onChangeStart / onChangeEnd" type="(v, meta) => void" desc="Gesture boundaries (drag, wheel burst, key press, reset) — ideal for undo history or parameter automation." />
|
||||||
|
<Row name="name" type="string" desc="Renders a hidden form input carrying the value — plain <form> posts and form libraries just work." />
|
||||||
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
|
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
|
||||||
<Row name="editable / parseValue" type="boolean / fn" def="false" desc="Type-in editing: double-click a knob (or click a fader readout), type a value, Enter commits, Esc cancels. '1.2k' → 1200." />
|
<Row name="editable / parseValue" type="boolean / fn" def="false" desc="Type-in editing: double-click a knob (or click a fader readout), type a value, Enter commits, Esc cancels. '1.2k' → 1200." />
|
||||||
<Row name="focusRing" type="string | false" def="theme" desc="Keyboard focus ring color. Pointer grabs never show it." />
|
<Row name="focusRing" type="string | false" def="theme" desc="Keyboard focus ring color. Pointer grabs never show it." />
|
||||||
|
|
@ -88,12 +94,19 @@ export const ApiDocs: React.FC = () => (
|
||||||
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." />
|
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." />
|
||||||
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
|
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
|
||||||
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
|
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
|
||||||
|
<Row name="<Meter />" type="display" desc="Read-only LED level meter: value, zones, peakHold (ms), peakColor, orientation, seven-segment readout." />
|
||||||
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
|
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
|
||||||
</Table>
|
</Table>
|
||||||
<p className="api-note">
|
<p className="api-note">
|
||||||
All skins also take <code>label</code>, <code>showValue</code>, <code>unit</code>,{' '}
|
All skins also take <code>label</code>, <code>showValue</code>, <code>unit</code>,{' '}
|
||||||
<code>format</code>, <code>className</code>, <code>style</code> plus every core prop
|
<code>format</code>, <code>className</code>, <code>style</code>, <code>name</code>{' '}
|
||||||
above.
|
plus every core prop above, and forward a ref to their root element. Inside the
|
||||||
|
SVG, parts carry <code>data-part</code> attributes (<code>arc</code>,{' '}
|
||||||
|
<code>track</code>, <code>pointer</code>, <code>ticks</code>, <code>face</code>,{' '}
|
||||||
|
<code>value</code>, <code>label</code>) and the root exposes{' '}
|
||||||
|
<code>data-dragging</code>/<code>data-disabled</code>/<code>data-focus-visible</code>{' '}
|
||||||
|
— style any state or part with plain CSS. <code><Arc></code> additionally
|
||||||
|
accepts <code>gradient</code> (position-anchored color stops).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="api-h">Theming</h3>
|
<h3 className="api-h">Theming</h3>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
LEDFader,
|
LEDFader,
|
||||||
LEDKnob,
|
LEDKnob,
|
||||||
MetalKnob,
|
MetalKnob,
|
||||||
|
Meter,
|
||||||
NeonKnob,
|
NeonKnob,
|
||||||
Pointer,
|
Pointer,
|
||||||
RubberKnob,
|
RubberKnob,
|
||||||
|
|
@ -57,6 +58,32 @@ const useGeneratedStrip = (frames = 31, fs = 120): string | null => {
|
||||||
return src;
|
return src;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MeterDemo: React.FC = () => {
|
||||||
|
const [levels, setLevels] = React.useState<[number, number]>([-18, -20]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
let t = 0;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
t += 0.09;
|
||||||
|
setLevels([
|
||||||
|
-13 + Math.sin(t) * 6 + Math.random() * 7,
|
||||||
|
-14 + Math.sin(t * 1.3 + 1) * 6 + Math.random() * 7,
|
||||||
|
]);
|
||||||
|
}, 90);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
const zones = [
|
||||||
|
{ upTo: 0.72, color: '#3df2ad' },
|
||||||
|
{ upTo: 0.9, color: '#ffd23e' },
|
||||||
|
{ upTo: 1, color: '#ff4d6b' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<Meter value={levels[0]} min={-60} max={6} length={132} zones={zones} peakColor="#fff" label="L" aria-label="Left level" />
|
||||||
|
<Meter value={levels[1]} min={-60} max={6} length={132} zones={zones} peakColor="#fff" label="R" aria-label="Right level" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ImageKnobDemo: React.FC = () => {
|
const ImageKnobDemo: React.FC = () => {
|
||||||
const src = useGeneratedStrip();
|
const src = useGeneratedStrip();
|
||||||
if (!src) return null;
|
if (!src) return null;
|
||||||
|
|
@ -136,8 +163,9 @@ export const Gallery: React.FC = () => (
|
||||||
<SteppedKnob size={92} min={-24} max={24} steps={9} color="#4cc2ff" defaultValue={0} label="Semi" aria-label="Semitones demo" />
|
<SteppedKnob size={92} min={-24} max={24} steps={9} color="#4cc2ff" defaultValue={0} label="Semi" aria-label="Semitones demo" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Fader" desc="Absolute-position linear control, both orientations." tag="<Fader />">
|
<Card title="Fader" desc="Absolute-position linear control, both orientations, custom caps." tag="<Fader />">
|
||||||
<Fader length={130} defaultValue={-6} min={-60} max={12} step={0.5} unit=" dB" label="Main" aria-label="Fader demo" />
|
<Fader length={130} defaultValue={-6} min={-60} max={12} step={0.5} unit=" dB" label="Main" aria-label="Fader demo" />
|
||||||
|
<Fader length={130} defaultValue={20} step={1} capColor="#e8e8ec" capLength={26} color="#3df2ad" label="Aux" aria-label="White cap fader demo" />
|
||||||
<Fader orientation="horizontal" length={150} defaultValue={35} step={1} color="#e44cff" label="X-Fade" aria-label="Crossfade demo" />
|
<Fader orientation="horizontal" length={150} defaultValue={35} step={1} color="#e44cff" label="X-Fade" aria-label="Crossfade demo" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -269,6 +297,49 @@ export const Gallery: React.FC = () => (
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Meter" desc="Read-only LED metering with peak hold — the display sibling of LEDFader." tag="<Meter peakHold />">
|
||||||
|
<MeterDemo />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Gradient arc" desc="Position-anchored gradient — the red zone stays at the top of travel." tag="<Arc gradient={...} />">
|
||||||
|
<Knob size={96} min={-60} max={6} step={0.5} defaultValue={-8} aria-label="Gradient arc demo">
|
||||||
|
<Arc
|
||||||
|
thickness={7}
|
||||||
|
trackColor="rgba(255,255,255,0.08)"
|
||||||
|
gradient={[
|
||||||
|
{ at: 0, color: '#3df2ad' },
|
||||||
|
{ at: 0.72, color: '#3df2ad' },
|
||||||
|
{ at: 0.9, color: '#ffd23e' },
|
||||||
|
{ at: 1, color: '#ff4d6b' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Pointer type="line" radius={33} length={12} width={3} color="#e8eaf0" />
|
||||||
|
<KnobValue unit=" dB" color="#e8eaf0" fontSize={14} />
|
||||||
|
</Knob>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Drag feel options" desc="Pickup mode never jumps on grab; acceleration makes fast vertical drags cover more range." tag="rotaryMode · dragAcceleration">
|
||||||
|
<FlatKnob
|
||||||
|
size={88}
|
||||||
|
defaultValue={70}
|
||||||
|
rotaryMode="pickup"
|
||||||
|
color="#ffd23e"
|
||||||
|
label="Pickup"
|
||||||
|
aria-label="Pickup mode demo"
|
||||||
|
/>
|
||||||
|
<RubberKnob
|
||||||
|
size={88}
|
||||||
|
defaultValue={40}
|
||||||
|
interaction="vertical"
|
||||||
|
dragAcceleration={2}
|
||||||
|
hideCursorOnDrag
|
||||||
|
showValue
|
||||||
|
color="#4cc2ff"
|
||||||
|
label="Accel"
|
||||||
|
aria-label="Acceleration demo"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="Compose your own" desc="Primitives + headless core = any design you want." tag="<Knob> + primitives">
|
<Card title="Compose your own" desc="Primitives + headless core = any design you want." tag="<Knob> + primitives">
|
||||||
<Knob size={96} defaultValue={42} aria-label="Composed demo">
|
<Knob size={96} defaultValue={42} aria-label="Composed demo">
|
||||||
<Ticks count={28} radius={47} length={5} width={1.5} color="rgba(255,255,255,0.12)" activeColor="#ffd23e" />
|
<Ticks count={28} radius={47} length={5} width={1.5} color="rgba(255,255,255,0.12)" activeColor="#ffd23e" />
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,15 @@ All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
|
||||||
- `detents={[0]}` + `detentSize` — magnetic snap points while dragging.
|
- `detents={[0]}` + `detentSize` — magnetic snap points while dragging.
|
||||||
- `editable` — double-click (knobs) or click the readout (faders) to type an exact
|
- `editable` — double-click (knobs) or click the readout (faders) to type an exact
|
||||||
value; `parseValue` customizes parsing (`"1.2k"` → 1200 out of the box).
|
value; `parseValue` customizes parsing (`"1.2k"` → 1200 out of the box).
|
||||||
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks.
|
- `rotaryMode` — `'absolute'` (rc-knob feel), `'relative'` (no grab jump), or
|
||||||
|
`'pickup'` (MIDI-style: engages when the pointer sweeps past the value).
|
||||||
|
- `dragAcceleration` — velocity gain for relative drags; `hideCursorOnDrag` hides
|
||||||
|
the cursor mid-gesture.
|
||||||
|
- `wheelStep` / `wheelRequiresFocus` — tune wheel notches, or keep hover-scroll free.
|
||||||
|
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks with
|
||||||
|
`meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'`).
|
||||||
|
- `name` — hidden form input for plain `<form>` posts; every component forwards a
|
||||||
|
ref to its root element, and SVG parts carry `data-part` attributes for CSS.
|
||||||
|
|
||||||
## Prebuilt skins
|
## Prebuilt skins
|
||||||
|
|
||||||
|
|
@ -80,6 +88,7 @@ import {
|
||||||
Fader, // linear channel fader, vertical or horizontal
|
Fader, // linear channel fader, vertical or horizontal
|
||||||
LEDFader, // segmented LED meter-fader with color zones
|
LEDFader, // segmented LED meter-fader with color zones
|
||||||
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
|
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
|
||||||
|
Meter, // read-only LED level meter with peak hold
|
||||||
SegmentDisplay, // standalone seven-segment numeric display
|
SegmentDisplay, // standalone seven-segment numeric display
|
||||||
} from 'dreamknob'
|
} from 'dreamknob'
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ export interface KnobProps extends KnobCoreProps {
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
/** Custom parser for typed input, e.g. to accept "-6 dB" or "A4". */
|
/** Custom parser for typed input, e.g. to accept "-6 dB" or "A4". */
|
||||||
parseValue?: (text: string) => number | null;
|
parseValue?: (text: string) => number | null;
|
||||||
|
/** Render a hidden form input carrying the current value. */
|
||||||
|
name?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
|
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
|
||||||
|
|
@ -33,17 +35,21 @@ export interface KnobProps extends KnobCoreProps {
|
||||||
* (rotary/relative/track drag, wheel, keyboard, double-click reset, a11y)
|
* (rotary/relative/track drag, wheel, keyboard, double-click reset, a11y)
|
||||||
* and provides a render context for composable SVG primitives.
|
* and provides a render context for composable SVG primitives.
|
||||||
*/
|
*/
|
||||||
export const Knob: React.FC<KnobProps> = ({
|
export const Knob = React.forwardRef<HTMLDivElement, KnobProps>(function Knob(
|
||||||
|
{
|
||||||
size = 80,
|
size = 80,
|
||||||
focusRing,
|
focusRing,
|
||||||
editable = false,
|
editable = false,
|
||||||
parseValue,
|
parseValue,
|
||||||
|
name,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
overlay,
|
overlay,
|
||||||
...core
|
...core
|
||||||
}) => {
|
},
|
||||||
|
forwardedRef,
|
||||||
|
) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const ring = focusRing ?? theme.focusRing;
|
const ring = focusRing ?? theme.focusRing;
|
||||||
const knob = useKnob(core);
|
const knob = useKnob(core);
|
||||||
|
|
@ -76,10 +82,17 @@ export const Knob: React.FC<KnobProps> = ({
|
||||||
setValue: knob.setValue,
|
setValue: knob.setValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setRefs = (node: HTMLDivElement | null) => {
|
||||||
|
(knob.bind.ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
if (typeof forwardedRef === 'function') forwardedRef(node);
|
||||||
|
else if (forwardedRef) forwardedRef.current = node;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KnobContextProvider value={ctx}>
|
<KnobContextProvider value={ctx}>
|
||||||
<div
|
<div
|
||||||
{...knob.bind}
|
{...knob.bind}
|
||||||
|
ref={setRefs}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
className={className}
|
className={className}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -114,7 +127,8 @@ export const Knob: React.FC<KnobProps> = ({
|
||||||
onCancel={closeEditor}
|
onCancel={closeEditor}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
|
||||||
</div>
|
</div>
|
||||||
</KnobContextProvider>
|
</KnobContextProvider>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
40
packages/dreamknob/src/core/colors.test.ts
Normal file
40
packages/dreamknob/src/core/colors.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mixColors, sampleGradient } from './colors';
|
||||||
|
|
||||||
|
describe('mixColors', () => {
|
||||||
|
it('mixes hex colors', () => {
|
||||||
|
expect(mixColors('#000000', '#ffffff', 0.5)).toBe('#808080');
|
||||||
|
expect(mixColors('#ff0000', '#00ff00', 0)).toBe('#ff0000');
|
||||||
|
expect(mixColors('#ff0000', '#00ff00', 1)).toBe('#00ff00');
|
||||||
|
});
|
||||||
|
it('expands shorthand hex', () => {
|
||||||
|
expect(mixColors('#f00', '#f00', 0.5)).toBe('#ff0000');
|
||||||
|
});
|
||||||
|
it('falls back to the nearer endpoint for non-hex input', () => {
|
||||||
|
expect(mixColors('red', '#00ff00', 0.2)).toBe('red');
|
||||||
|
expect(mixColors('red', '#00ff00', 0.8)).toBe('#00ff00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sampleGradient', () => {
|
||||||
|
const stops = [
|
||||||
|
{ at: 0, color: '#000000' },
|
||||||
|
{ at: 0.5, color: '#808080' },
|
||||||
|
{ at: 1, color: '#ffffff' },
|
||||||
|
];
|
||||||
|
it('returns stop colors at their positions', () => {
|
||||||
|
expect(sampleGradient(stops, 0)).toBe('#000000');
|
||||||
|
expect(sampleGradient(stops, 0.5)).toBe('#808080');
|
||||||
|
expect(sampleGradient(stops, 1)).toBe('#ffffff');
|
||||||
|
});
|
||||||
|
it('interpolates between stops', () => {
|
||||||
|
expect(sampleGradient(stops, 0.25)).toBe('#404040');
|
||||||
|
});
|
||||||
|
it('clamps outside the stop range', () => {
|
||||||
|
expect(sampleGradient(stops, -1)).toBe('#000000');
|
||||||
|
expect(sampleGradient(stops, 2)).toBe('#ffffff');
|
||||||
|
});
|
||||||
|
it('handles unsorted stops', () => {
|
||||||
|
expect(sampleGradient([stops[2], stops[0], stops[1]], 0.25)).toBe('#404040');
|
||||||
|
});
|
||||||
|
});
|
||||||
48
packages/dreamknob/src/core/colors.ts
Normal file
48
packages/dreamknob/src/core/colors.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/** A color stop along the normalized travel (at ∈ [0, 1]). */
|
||||||
|
export interface GradientStop {
|
||||||
|
at: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseHex = (color: string): [number, number, number, number] | null => {
|
||||||
|
const m = color.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||||
|
if (!m) return null;
|
||||||
|
let h = m[1];
|
||||||
|
if (h.length <= 4) h = [...h].map(c => c + c).join('');
|
||||||
|
const n = parseInt(h.length === 6 ? `${h}ff` : h, 16);
|
||||||
|
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mix two hex colors. Non-hex inputs fall back to the nearer endpoint. */
|
||||||
|
export const mixColors = (a: string, b: string, t: number): string => {
|
||||||
|
const ca = parseHex(a);
|
||||||
|
const cb = parseHex(b);
|
||||||
|
if (!ca || !cb) return t < 0.5 ? a : b;
|
||||||
|
const lerp = (x: number, y: number) => Math.round(x + (y - x) * t);
|
||||||
|
const [r, g, bl, al] = [0, 1, 2, 3].map(i => lerp(ca[i], cb[i]));
|
||||||
|
const hex = (v: number) => v.toString(16).padStart(2, '0');
|
||||||
|
return al === 255
|
||||||
|
? `#${hex(r)}${hex(g)}${hex(bl)}`
|
||||||
|
: `#${hex(r)}${hex(g)}${hex(bl)}${hex(al)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample a gradient at normalized position `t`. Stops are sorted by `at`;
|
||||||
|
* positions outside the stops clamp to the nearest end.
|
||||||
|
*/
|
||||||
|
export const sampleGradient = (stops: readonly GradientStop[], t: number): string => {
|
||||||
|
if (stops.length === 0) return '#000';
|
||||||
|
const sorted = [...stops].sort((a, b) => a.at - b.at);
|
||||||
|
if (t <= sorted[0].at) return sorted[0].color;
|
||||||
|
const last = sorted[sorted.length - 1];
|
||||||
|
if (t >= last.at) return last.color;
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
if (t <= sorted[i].at) {
|
||||||
|
const a = sorted[i - 1];
|
||||||
|
const b = sorted[i];
|
||||||
|
const span = b.at - a.at;
|
||||||
|
return mixColors(a.color, b.color, span > 0 ? (t - a.at) / span : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last.color;
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,21 @@
|
||||||
import type { Taper } from './math';
|
import type { Taper } from './math';
|
||||||
|
|
||||||
|
/** What produced a value change. */
|
||||||
|
export type ChangeSource = 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api';
|
||||||
|
|
||||||
|
export interface ChangeMeta {
|
||||||
|
source: ChangeSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the grab-and-rotate pointer angle maps to the value in rotary mode. */
|
||||||
|
export type RotaryMode =
|
||||||
|
/** Value tracks the pointer's absolute angle (rc-knob feel). Default. */
|
||||||
|
| 'absolute'
|
||||||
|
/** Angle *deltas* adjust the value — no jump when grabbing off-position. */
|
||||||
|
| 'relative'
|
||||||
|
/** Ignore the pointer until it sweeps past the current position, then track it (MIDI pickup). */
|
||||||
|
| 'pickup';
|
||||||
|
|
||||||
/** How pointer dragging maps to value changes. */
|
/** How pointer dragging maps to value changes. */
|
||||||
export type InteractionMode =
|
export type InteractionMode =
|
||||||
/** Track the pointer's absolute angle around the knob center (grab-and-rotate, rc-knob feel). */
|
/** Track the pointer's absolute angle around the knob center (grab-and-rotate, rc-knob feel). */
|
||||||
|
|
@ -53,8 +69,18 @@ export interface KnobCoreProps {
|
||||||
detentSize?: number;
|
detentSize?: number;
|
||||||
/** Drag behaviour. Default: 'rotary'. */
|
/** Drag behaviour. Default: 'rotary'. */
|
||||||
interaction?: InteractionMode;
|
interaction?: InteractionMode;
|
||||||
|
/** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */
|
||||||
|
rotaryMode?: RotaryMode;
|
||||||
/** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */
|
/** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */
|
||||||
dragSensitivity?: number;
|
dragSensitivity?: number;
|
||||||
|
/**
|
||||||
|
* Velocity-based gain for relative drags (vertical/horizontal/both and
|
||||||
|
* relative rotary): fast drags cover more range, slow drags stay precise.
|
||||||
|
* 0 disables (default); 1–3 are sensible. Fine mode (Shift) bypasses it.
|
||||||
|
*/
|
||||||
|
dragAcceleration?: number;
|
||||||
|
/** Hide the mouse cursor while dragging (FabFilter style). Default: false. */
|
||||||
|
hideCursorOnDrag?: boolean;
|
||||||
/**
|
/**
|
||||||
* Dead margin (px) at each end of the element in `track-*` modes, so pointer
|
* Dead margin (px) at each end of the element in `track-*` modes, so pointer
|
||||||
* mapping lines up with a handle that can't travel the full rect (half the
|
* mapping lines up with a handle that can't travel the full rect (half the
|
||||||
|
|
@ -69,16 +95,23 @@ export interface KnobCoreProps {
|
||||||
angleRange?: number;
|
angleRange?: number;
|
||||||
/** Enable mouse-wheel adjustment. Default: true. */
|
/** Enable mouse-wheel adjustment. Default: true. */
|
||||||
enableWheel?: boolean;
|
enableWheel?: boolean;
|
||||||
|
/** Value change per wheel notch. Defaults to `step` (or 1% of the range). */
|
||||||
|
wheelStep?: number;
|
||||||
|
/**
|
||||||
|
* Only respond to the wheel while the control is focused, letting the page
|
||||||
|
* scroll normally otherwise. Default: false (hovering captures the wheel).
|
||||||
|
*/
|
||||||
|
wheelRequiresFocus?: boolean;
|
||||||
/** Reset to `defaultValue` on double click. Default: true when `defaultValue` is set. */
|
/** Reset to `defaultValue` on double click. Default: true when `defaultValue` is set. */
|
||||||
doubleClickReset?: boolean;
|
doubleClickReset?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
/** Fired with each (rounded, snapped) value change. */
|
/** Fired with each (rounded, snapped) value change, with what caused it. */
|
||||||
onChange?: (value: number) => void;
|
onChange?: (value: number, meta: ChangeMeta) => void;
|
||||||
/** Fired when an adjustment gesture ends (pointer up, wheel settle, key release). */
|
/** Fired when an adjustment gesture ends (pointer up, wheel settle, key release). */
|
||||||
onChangeEnd?: (value: number) => void;
|
onChangeEnd?: (value: number, meta: ChangeMeta) => void;
|
||||||
/** Fired when a drag gesture starts. */
|
/** Fired when an adjustment gesture starts. */
|
||||||
onChangeStart?: (value: number) => void;
|
onChangeStart?: (value: number, meta: ChangeMeta) => void;
|
||||||
/** Accessible name for the slider role. */
|
/** Accessible name for the slider role. */
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
'aria-labelledby'?: string;
|
'aria-labelledby'?: string;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
roundTo,
|
roundTo,
|
||||||
snapToStep,
|
snapToStep,
|
||||||
} from '../core/math';
|
} from '../core/math';
|
||||||
import type { KnobCoreProps, KnobState } from '../core/types';
|
import type { ChangeSource, KnobCoreProps, KnobState } from '../core/types';
|
||||||
|
|
||||||
export interface UseKnobResult extends KnobState {
|
export interface UseKnobResult extends KnobState {
|
||||||
/** True when focus came from the keyboard — render a focus ring. */
|
/** True when focus came from the keyboard — render a focus ring. */
|
||||||
|
|
@ -59,12 +59,22 @@ interface DragSession {
|
||||||
rect: DOMRect;
|
rect: DOMRect;
|
||||||
lastX: number;
|
lastX: number;
|
||||||
lastY: number;
|
lastY: number;
|
||||||
|
lastT: number;
|
||||||
/** Continuous normalized position, kept un-snapped for smooth relative drags. */
|
/** Continuous normalized position, kept un-snapped for smooth relative drags. */
|
||||||
n: number;
|
n: number;
|
||||||
/** Value at gesture start, restored when Escape cancels the drag. */
|
/** Value at gesture start, restored when Escape cancels the drag. */
|
||||||
startValue: number;
|
startValue: number;
|
||||||
|
/** Previous pointer angle (relative rotary mode). */
|
||||||
|
prevAngle?: number;
|
||||||
|
/** Previous pointer position in normalized space (pickup mode). */
|
||||||
|
prevPointerN?: number;
|
||||||
|
/** Pickup mode: whether the pointer has reached the value yet. */
|
||||||
|
engaged: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = (): number =>
|
||||||
|
typeof performance !== 'undefined' ? performance.now() : 0;
|
||||||
|
|
||||||
export function useKnob(props: KnobCoreProps): UseKnobResult {
|
export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
const {
|
const {
|
||||||
min: rawMin = 0,
|
min: rawMin = 0,
|
||||||
|
|
@ -74,7 +84,10 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
steps,
|
steps,
|
||||||
taper = linearTaper,
|
taper = linearTaper,
|
||||||
interaction = 'rotary',
|
interaction = 'rotary',
|
||||||
|
rotaryMode = 'absolute',
|
||||||
dragSensitivity = 200,
|
dragSensitivity = 200,
|
||||||
|
dragAcceleration = 0,
|
||||||
|
hideCursorOnDrag = false,
|
||||||
trackInset = 0,
|
trackInset = 0,
|
||||||
origin,
|
origin,
|
||||||
detents,
|
detents,
|
||||||
|
|
@ -83,6 +96,8 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
angleOffset = 225,
|
angleOffset = 225,
|
||||||
angleRange = 270,
|
angleRange = 270,
|
||||||
enableWheel = true,
|
enableWheel = true,
|
||||||
|
wheelStep,
|
||||||
|
wheelRequiresFocus = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -137,7 +152,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
onChangeStartRef.current = onChangeStart;
|
onChangeStartRef.current = onChangeStart;
|
||||||
|
|
||||||
const emit = React.useCallback(
|
const emit = React.useCallback(
|
||||||
(raw: number) => {
|
(raw: number, source: ChangeSource = 'api') => {
|
||||||
const next = constrain(raw);
|
const next = constrain(raw);
|
||||||
if (next === valueRef.current) return;
|
if (next === valueRef.current) return;
|
||||||
// Only pre-commit in uncontrolled mode. In controlled mode the parent
|
// Only pre-commit in uncontrolled mode. In controlled mode the parent
|
||||||
|
|
@ -147,13 +162,14 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
valueRef.current = next;
|
valueRef.current = next;
|
||||||
setInternalValue(next);
|
setInternalValue(next);
|
||||||
}
|
}
|
||||||
onChange?.(next);
|
onChange?.(next, { source });
|
||||||
},
|
},
|
||||||
[constrain, isControlled, onChange],
|
[constrain, isControlled, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setFromNormalized = React.useCallback(
|
const setFromNormalized = React.useCallback(
|
||||||
(n: number) => emit(taper.fromNormalized(clamp(n, 0, 1), min, max)),
|
(n: number, source: ChangeSource = 'api') =>
|
||||||
|
emit(taper.fromNormalized(clamp(n, 0, 1), min, max), source),
|
||||||
[emit, taper, min, max],
|
[emit, taper, min, max],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -165,7 +181,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
);
|
);
|
||||||
const setFromDrag = React.useCallback(
|
const setFromDrag = React.useCallback(
|
||||||
(n: number) =>
|
(n: number) =>
|
||||||
setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n),
|
setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n, 'drag'),
|
||||||
[setFromNormalized, detentsN, detentSize],
|
[setFromNormalized, detentsN, detentSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -179,26 +195,62 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
(clientX: number, clientY: number, fine: boolean) => {
|
(clientX: number, clientY: number, fine: boolean) => {
|
||||||
const s = session.current;
|
const s = session.current;
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
|
const t = now();
|
||||||
|
const dt = Math.max(1, t - s.lastT);
|
||||||
|
const dist = Math.hypot(clientX - s.lastX, clientY - s.lastY);
|
||||||
|
// Velocity gain for relative modes; capped so a flick can't overshoot
|
||||||
|
// the whole range in one event.
|
||||||
|
const gain =
|
||||||
|
fine || dragAcceleration <= 0
|
||||||
|
? 1
|
||||||
|
: 1 + dragAcceleration * Math.min(dist / dt, 4);
|
||||||
|
|
||||||
switch (interaction) {
|
switch (interaction) {
|
||||||
case 'rotary': {
|
case 'rotary': {
|
||||||
const angle = angleFromPoint(clientX, clientY, s.centerX, s.centerY);
|
const angle = angleFromPoint(clientX, clientY, s.centerX, s.centerY);
|
||||||
setFromDrag(normalizedFromAngle(angle, angleOffset % 360, angleRange));
|
if (rotaryMode === 'relative') {
|
||||||
|
if (s.prevAngle !== undefined) {
|
||||||
|
let delta = angle - s.prevAngle;
|
||||||
|
if (delta > 180) delta -= 360;
|
||||||
|
if (delta < -180) delta += 360;
|
||||||
|
const dn = (delta / angleRange) * (fine ? fineMultiplier : 1) * gain;
|
||||||
|
s.n = clamp(s.n + dn, 0, 1);
|
||||||
|
setFromDrag(s.n);
|
||||||
|
}
|
||||||
|
s.prevAngle = angle;
|
||||||
|
} else {
|
||||||
|
const n = normalizedFromAngle(angle, angleOffset % 360, angleRange);
|
||||||
|
if (!s.engaged) {
|
||||||
|
// Pickup: engage when the pointer reaches or sweeps across the
|
||||||
|
// current position, so grabbing off-position never jumps.
|
||||||
|
const crossed =
|
||||||
|
s.prevPointerN !== undefined &&
|
||||||
|
(s.prevPointerN - s.n) * (n - s.n) <= 0;
|
||||||
|
if (Math.abs(n - s.n) <= 0.04 || crossed) s.engaged = true;
|
||||||
|
s.prevPointerN = n;
|
||||||
|
if (!s.engaged) break;
|
||||||
|
}
|
||||||
|
s.n = n;
|
||||||
|
setFromDrag(n);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'track-vertical': {
|
case 'track-vertical': {
|
||||||
const travel = s.rect.height - trackInset * 2;
|
const travel = s.rect.height - trackInset * 2;
|
||||||
if (travel <= 0) break;
|
if (travel <= 0) break;
|
||||||
setFromDrag((s.rect.bottom - trackInset - clientY) / travel);
|
s.n = clamp((s.rect.bottom - trackInset - clientY) / travel, 0, 1);
|
||||||
|
setFromDrag(s.n);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'track-horizontal': {
|
case 'track-horizontal': {
|
||||||
const travel = s.rect.width - trackInset * 2;
|
const travel = s.rect.width - trackInset * 2;
|
||||||
if (travel <= 0) break;
|
if (travel <= 0) break;
|
||||||
setFromDrag((clientX - s.rect.left - trackInset) / travel);
|
s.n = clamp((clientX - s.rect.left - trackInset) / travel, 0, 1);
|
||||||
|
setFromDrag(s.n);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
const scale = (fine ? fineMultiplier : 1) / dragSensitivity;
|
const scale = ((fine ? fineMultiplier : 1) * gain) / dragSensitivity;
|
||||||
let dn = 0;
|
let dn = 0;
|
||||||
if (interaction === 'vertical' || interaction === 'both')
|
if (interaction === 'vertical' || interaction === 'both')
|
||||||
dn += (s.lastY - clientY) * scale;
|
dn += (s.lastY - clientY) * scale;
|
||||||
|
|
@ -211,8 +263,19 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
}
|
}
|
||||||
s.lastX = clientX;
|
s.lastX = clientX;
|
||||||
s.lastY = clientY;
|
s.lastY = clientY;
|
||||||
|
s.lastT = t;
|
||||||
},
|
},
|
||||||
[interaction, angleOffset, angleRange, dragSensitivity, trackInset, fineMultiplier, setFromDrag],
|
[
|
||||||
|
interaction,
|
||||||
|
rotaryMode,
|
||||||
|
angleOffset,
|
||||||
|
angleRange,
|
||||||
|
dragSensitivity,
|
||||||
|
dragAcceleration,
|
||||||
|
trackInset,
|
||||||
|
fineMultiplier,
|
||||||
|
setFromDrag,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerDown = React.useCallback(
|
const onPointerDown = React.useCallback(
|
||||||
|
|
@ -234,17 +297,19 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
centerY: rect.top + rect.height / 2,
|
centerY: rect.top + rect.height / 2,
|
||||||
lastX: e.clientX,
|
lastX: e.clientX,
|
||||||
lastY: e.clientY,
|
lastY: e.clientY,
|
||||||
|
lastT: now(),
|
||||||
n: clamp(taper.toNormalized(valueRef.current, min, max), 0, 1),
|
n: clamp(taper.toNormalized(valueRef.current, min, max), 0, 1),
|
||||||
startValue: valueRef.current,
|
startValue: valueRef.current,
|
||||||
|
engaged: !(interaction === 'rotary' && rotaryMode === 'pickup'),
|
||||||
};
|
};
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
onChangeStartRef.current?.(valueRef.current);
|
onChangeStartRef.current?.(valueRef.current, { source: 'drag' });
|
||||||
// Fader-style modes jump to the pressed position immediately.
|
// Fader-style modes jump to the pressed position immediately.
|
||||||
if (interaction === 'track-vertical' || interaction === 'track-horizontal') {
|
if (interaction === 'track-vertical' || interaction === 'track-horizontal') {
|
||||||
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[interactive, interaction, taper, min, max, applyPointer],
|
[interactive, interaction, rotaryMode, taper, min, max, applyPointer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerMove = React.useCallback(
|
const onPointerMove = React.useCallback(
|
||||||
|
|
@ -256,29 +321,45 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
[interactive, applyPointer],
|
[interactive, applyPointer],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const endDrag = React.useCallback((e: React.PointerEvent) => {
|
||||||
|
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||||
|
session.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// If the control becomes disabled/readOnly mid-gesture, cancel the drag —
|
// If the control becomes disabled/readOnly mid-gesture, cancel the drag —
|
||||||
// otherwise it would keep emitting while rendered as disabled.
|
// otherwise it would keep emitting while rendered as disabled.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (interactive || !session.current) return;
|
if (interactive || !session.current) return;
|
||||||
session.current = null;
|
session.current = null;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
onChangeEndRef.current?.(valueRef.current);
|
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
|
||||||
}, [interactive]);
|
}, [interactive]);
|
||||||
|
|
||||||
const endDrag = React.useCallback((e: React.PointerEvent) => {
|
// Optionally hide the mouse cursor for the duration of a drag.
|
||||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
React.useEffect(() => {
|
||||||
session.current = null;
|
if (!hideCursorOnDrag || !isDragging || typeof document === 'undefined') return;
|
||||||
setIsDragging(false);
|
const prev = document.body.style.cursor;
|
||||||
onChangeEndRef.current?.(valueRef.current);
|
document.body.style.cursor = 'none';
|
||||||
}, []);
|
return () => {
|
||||||
|
document.body.style.cursor = prev;
|
||||||
|
};
|
||||||
|
}, [hideCursorOnDrag, isDragging]);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Wheel — needs a non-passive listener so preventDefault stops page scroll.
|
// Wheel — needs a non-passive listener so preventDefault stops page scroll.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const wheelSettle = React.useRef<ReturnType<typeof setTimeout>>();
|
const wheelSettle = React.useRef<ReturnType<typeof setTimeout>>();
|
||||||
const wheelActive = React.useRef(false);
|
const wheelActive = React.useRef(false);
|
||||||
|
const wheelAccum = React.useRef(0);
|
||||||
const nudge = React.useCallback(
|
const nudge = React.useCallback(
|
||||||
(direction: number, multiplier = 1) => {
|
(
|
||||||
|
direction: number,
|
||||||
|
multiplier = 1,
|
||||||
|
source: ChangeSource = 'api',
|
||||||
|
stepOverride?: number,
|
||||||
|
) => {
|
||||||
const current = valueRef.current;
|
const current = valueRef.current;
|
||||||
// Discrete controls move whole detents: fine mode still moves one,
|
// Discrete controls move whole detents: fine mode still moves one,
|
||||||
// coarse mode jumps several.
|
// coarse mode jumps several.
|
||||||
|
|
@ -286,16 +367,18 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
if (values && values.length > 0) {
|
if (values && values.length > 0) {
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
const idx = sorted.indexOf(findClosest(sorted, current));
|
const idx = sorted.indexOf(findClosest(sorted, current));
|
||||||
emit(sorted[clamp(idx + direction * detentCount, 0, sorted.length - 1)]);
|
emit(sorted[clamp(idx + direction * detentCount, 0, sorted.length - 1)], source);
|
||||||
} else if (steps && steps > 1) {
|
} else if (steps && steps > 1) {
|
||||||
const n = taper.toNormalized(current, min, max);
|
const n = taper.toNormalized(current, min, max);
|
||||||
setFromNormalized(n + (direction * detentCount) / (steps - 1));
|
setFromNormalized(n + (direction * detentCount) / (steps - 1), source);
|
||||||
} else if (step > 0) {
|
} else if (stepOverride !== undefined || step > 0) {
|
||||||
|
const base = stepOverride ?? step;
|
||||||
// A fractional multiplier would snap straight back to the current
|
// A fractional multiplier would snap straight back to the current
|
||||||
// step — never emit less than one step.
|
// step — never emit less than one step when snapping is on.
|
||||||
emit(current + direction * step * Math.max(1, multiplier));
|
const effective = step > 0 ? Math.max(1, multiplier) : multiplier;
|
||||||
|
emit(current + direction * base * effective, source);
|
||||||
} else {
|
} else {
|
||||||
emit(current + direction * ((max - min) / 100) * multiplier);
|
emit(current + direction * ((max - min) / 100) * multiplier, source);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[values, steps, step, min, max, taper, emit, setFromNormalized],
|
[values, steps, step, min, max, taper, emit, setFromNormalized],
|
||||||
|
|
@ -307,24 +390,47 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el || !enableWheel || !interactive) return;
|
if (!el || !enableWheel || !interactive) return;
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
// Opt-out of the hover scroll-trap: let the page scroll unless focused.
|
||||||
|
if (wheelRequiresFocus && document.activeElement !== el) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// A wheel tick mid-drag would be undone by the next pointermove
|
// A wheel tick mid-drag would be undone by the next pointermove
|
||||||
// (the drag session tracks its own position) — ignore it.
|
// (the drag session tracks its own position) — ignore it.
|
||||||
if (session.current) return;
|
if (session.current) return;
|
||||||
if (!wheelActive.current) {
|
|
||||||
wheelActive.current = true;
|
|
||||||
onChangeStartRef.current?.(valueRef.current);
|
|
||||||
}
|
|
||||||
// Dominant axis, scroll-up / scroll-right increases. Using deltaY with
|
// Dominant axis, scroll-up / scroll-right increases. Using deltaY with
|
||||||
// deltaX only as fallback keeps macOS Shift+scroll (which remaps Y to X)
|
// deltaX only as fallback keeps macOS Shift+scroll (which remaps Y to X)
|
||||||
// from reversing direction.
|
// from reversing direction.
|
||||||
const d = e.deltaY !== 0 ? -e.deltaY : -e.deltaX;
|
const raw = e.deltaY !== 0 ? -e.deltaY : -e.deltaX;
|
||||||
const direction = d > 0 ? 1 : -1;
|
// Normalize notches: a discrete wheel click (|delta| ~100) is one notch;
|
||||||
nudgeRef.current(direction, e.shiftKey ? fineMultiplier : 1);
|
// small trackpad deltas accumulate so a flick can't rocket the value.
|
||||||
|
let notches: number;
|
||||||
|
if (e.deltaMode !== 0) {
|
||||||
|
notches = Math.sign(raw) * Math.max(1, Math.round(Math.abs(raw) / 3));
|
||||||
|
wheelAccum.current = 0;
|
||||||
|
} else if (Math.abs(raw) >= 50) {
|
||||||
|
notches = Math.sign(raw) * Math.max(1, Math.round(Math.abs(raw) / 100));
|
||||||
|
wheelAccum.current = 0;
|
||||||
|
} else {
|
||||||
|
if (Math.sign(raw) !== Math.sign(wheelAccum.current)) wheelAccum.current = 0;
|
||||||
|
wheelAccum.current += raw;
|
||||||
|
notches = Math.trunc(wheelAccum.current / 50);
|
||||||
|
wheelAccum.current -= notches * 50;
|
||||||
|
}
|
||||||
|
if (!notches) return;
|
||||||
|
if (!wheelActive.current) {
|
||||||
|
wheelActive.current = true;
|
||||||
|
onChangeStartRef.current?.(valueRef.current, { source: 'wheel' });
|
||||||
|
}
|
||||||
|
nudgeRef.current(
|
||||||
|
Math.sign(notches),
|
||||||
|
Math.abs(notches) * (e.shiftKey ? fineMultiplier : 1),
|
||||||
|
'wheel',
|
||||||
|
wheelStep,
|
||||||
|
);
|
||||||
clearTimeout(wheelSettle.current);
|
clearTimeout(wheelSettle.current);
|
||||||
wheelSettle.current = setTimeout(() => {
|
wheelSettle.current = setTimeout(() => {
|
||||||
wheelActive.current = false;
|
wheelActive.current = false;
|
||||||
onChangeEndRef.current?.(valueRef.current);
|
wheelAccum.current = 0;
|
||||||
|
onChangeEndRef.current?.(valueRef.current, { source: 'wheel' });
|
||||||
}, 250);
|
}, 250);
|
||||||
};
|
};
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|
@ -335,10 +441,10 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
// gesture's onChangeEnd.
|
// gesture's onChangeEnd.
|
||||||
if (wheelActive.current) {
|
if (wheelActive.current) {
|
||||||
wheelActive.current = false;
|
wheelActive.current = false;
|
||||||
onChangeEndRef.current?.(valueRef.current);
|
onChangeEndRef.current?.(valueRef.current, { source: 'wheel' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [enableWheel, interactive, fineMultiplier]);
|
}, [enableWheel, interactive, fineMultiplier, wheelStep, wheelRequiresFocus]);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Keyboard
|
// Keyboard
|
||||||
|
|
@ -354,8 +460,8 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
session.current = null;
|
session.current = null;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
emit(s.startValue);
|
emit(s.startValue, 'drag');
|
||||||
onChangeEndRef.current?.(s.startValue);
|
onChangeEndRef.current?.(s.startValue, { source: 'drag' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const span = max - min;
|
const span = max - min;
|
||||||
|
|
@ -363,23 +469,23 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
nudge(1, e.shiftKey ? 10 : 1);
|
nudge(1, e.shiftKey ? 10 : 1, 'keyboard');
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
nudge(-1, e.shiftKey ? 10 : 1);
|
nudge(-1, e.shiftKey ? 10 : 1, 'keyboard');
|
||||||
break;
|
break;
|
||||||
case 'PageUp':
|
case 'PageUp':
|
||||||
emit(valueRef.current + span / 10);
|
emit(valueRef.current + span / 10, 'keyboard');
|
||||||
break;
|
break;
|
||||||
case 'PageDown':
|
case 'PageDown':
|
||||||
emit(valueRef.current - span / 10);
|
emit(valueRef.current - span / 10, 'keyboard');
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
emit(min);
|
emit(min, 'keyboard');
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
emit(max);
|
emit(max, 'keyboard');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
handled = false;
|
handled = false;
|
||||||
|
|
@ -395,7 +501,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
const onKeyUp = React.useCallback(() => {
|
const onKeyUp = React.useCallback(() => {
|
||||||
if (keyAdjusted.current) {
|
if (keyAdjusted.current) {
|
||||||
keyAdjusted.current = false;
|
keyAdjusted.current = false;
|
||||||
onChangeEndRef.current?.(valueRef.current);
|
onChangeEndRef.current?.(valueRef.current, { source: 'keyboard' });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -412,16 +518,16 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
// Flush a pending gesture end if the user tabs away while holding a key.
|
// Flush a pending gesture end if the user tabs away while holding a key.
|
||||||
if (keyAdjusted.current) {
|
if (keyAdjusted.current) {
|
||||||
keyAdjusted.current = false;
|
keyAdjusted.current = false;
|
||||||
onChangeEndRef.current?.(valueRef.current);
|
onChangeEndRef.current?.(valueRef.current, { source: 'keyboard' });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onDoubleClick = React.useCallback(() => {
|
const onDoubleClick = React.useCallback(() => {
|
||||||
if (!interactive || !doubleClickReset || props.defaultValue === undefined) return;
|
if (!interactive || !doubleClickReset || props.defaultValue === undefined) return;
|
||||||
const target = constrain(props.defaultValue);
|
const target = constrain(props.defaultValue);
|
||||||
onChangeStartRef.current?.(valueRef.current);
|
onChangeStartRef.current?.(valueRef.current, { source: 'reset' });
|
||||||
emit(target);
|
emit(target, 'reset');
|
||||||
onChangeEndRef.current?.(target);
|
onChangeEndRef.current?.(target, { source: 'reset' });
|
||||||
}, [interactive, doubleClickReset, props.defaultValue, emit, constrain]);
|
}, [interactive, doubleClickReset, props.defaultValue, emit, constrain]);
|
||||||
|
|
||||||
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
|
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
|
||||||
|
|
@ -485,7 +591,9 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
: readOnly
|
: readOnly
|
||||||
? 'default'
|
? 'default'
|
||||||
: isDragging
|
: isDragging
|
||||||
? 'grabbing'
|
? hideCursorOnDrag
|
||||||
|
? 'none'
|
||||||
|
: 'grabbing'
|
||||||
: 'grab',
|
: 'grab',
|
||||||
opacity: disabled ? 0.45 : undefined,
|
opacity: disabled ? 0.45 : undefined,
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,15 @@ export {
|
||||||
type KnobTheme,
|
type KnobTheme,
|
||||||
type DreamknobProviderProps,
|
type DreamknobProviderProps,
|
||||||
} from './core/theme';
|
} from './core/theme';
|
||||||
export type { KnobCoreProps, KnobState, InteractionMode } from './core/types';
|
export type {
|
||||||
|
KnobCoreProps,
|
||||||
|
KnobState,
|
||||||
|
InteractionMode,
|
||||||
|
RotaryMode,
|
||||||
|
ChangeMeta,
|
||||||
|
ChangeSource,
|
||||||
|
} from './core/types';
|
||||||
|
export { mixColors, sampleGradient, type GradientStop } from './core/colors';
|
||||||
export {
|
export {
|
||||||
clamp,
|
clamp,
|
||||||
roundTo,
|
roundTo,
|
||||||
|
|
@ -57,4 +65,5 @@ export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
|
||||||
export { Fader, type FaderProps } from './skins/Fader';
|
export { Fader, type FaderProps } from './skins/Fader';
|
||||||
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
|
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
|
||||||
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
|
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
|
||||||
|
export { Meter, type MeterProps } from './skins/Meter';
|
||||||
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useKnobContext } from '../core/context';
|
import { useKnobContext } from '../core/context';
|
||||||
import { describeArc } from '../core/math';
|
import { describeArc } from '../core/math';
|
||||||
|
import { sampleGradient, type GradientStop } from '../core/colors';
|
||||||
|
|
||||||
export interface ArcProps {
|
export interface ArcProps {
|
||||||
/** Arc radius. Defaults to (size - thickness) / 2. */
|
/** Arc radius. Defaults to (size - thickness) / 2. */
|
||||||
|
|
@ -8,6 +9,13 @@ export interface ArcProps {
|
||||||
thickness?: number;
|
thickness?: number;
|
||||||
/** Value arc color. */
|
/** Value arc color. */
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/**
|
||||||
|
* Position-anchored gradient for the value arc, e.g. green→yellow→red:
|
||||||
|
* `[{at: 0, color: '#3df2ad'}, {at: 0.8, color: '#ffd23e'}, {at: 1, color: '#ff4d6b'}]`.
|
||||||
|
* Colors stay fixed in space (the red zone is always at the top of travel).
|
||||||
|
* Overrides `color`.
|
||||||
|
*/
|
||||||
|
gradient?: readonly GradientStop[];
|
||||||
/** Background track color. Omit to skip the track. */
|
/** Background track color. Omit to skip the track. */
|
||||||
trackColor?: string;
|
trackColor?: string;
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,6 +35,7 @@ export const Arc: React.FC<ArcProps> = ({
|
||||||
radius,
|
radius,
|
||||||
thickness = 4,
|
thickness = 4,
|
||||||
color = 'currentColor',
|
color = 'currentColor',
|
||||||
|
gradient,
|
||||||
trackColor,
|
trackColor,
|
||||||
from = 'min',
|
from = 'min',
|
||||||
cap = 'round',
|
cap = 'round',
|
||||||
|
|
@ -49,19 +58,39 @@ export const Arc: React.FC<ArcProps> = ({
|
||||||
end = angleOffset + normalized * angleRange;
|
end = angleOffset + normalized * angleRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
let valueArc: React.ReactNode = null;
|
||||||
<g opacity={opacity}>
|
if (end - start > 0.0001) {
|
||||||
{trackColor && (
|
if (gradient && gradient.length > 0) {
|
||||||
|
// SVG can't run a gradient along a path, so approximate with short
|
||||||
|
// butt-capped slices sampled from the gradient at their absolute
|
||||||
|
// position across the travel.
|
||||||
|
const sweep = end - start;
|
||||||
|
const slices = Math.max(2, Math.ceil(sweep / 4));
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
for (let i = 0; i < slices; i++) {
|
||||||
|
const a0 = start + (i / slices) * sweep;
|
||||||
|
const a1 = start + ((i + 1) / slices) * sweep + (i < slices - 1 ? 0.35 : 0);
|
||||||
|
const mid = (a0 + a1) / 2;
|
||||||
|
parts.push(
|
||||||
<path
|
<path
|
||||||
d={describeArc(center, center, r, angleOffset, angleOffset + angleRange)}
|
key={i}
|
||||||
stroke={trackColor}
|
d={describeArc(center, center, r, a0, a1)}
|
||||||
|
stroke={sampleGradient(gradient, (mid - angleOffset) / angleRange)}
|
||||||
strokeWidth={thickness}
|
strokeWidth={thickness}
|
||||||
strokeLinecap={cap}
|
strokeLinecap="butt"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>,
|
||||||
)}
|
);
|
||||||
{end - start > 0.0001 && (
|
}
|
||||||
|
valueArc = (
|
||||||
|
<g data-part="arc" {...(arcProps as React.SVGProps<SVGGElement>)}>
|
||||||
|
{parts}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
valueArc = (
|
||||||
<path
|
<path
|
||||||
|
data-part="arc"
|
||||||
d={describeArc(center, center, r, start, end)}
|
d={describeArc(center, center, r, start, end)}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={thickness}
|
strokeWidth={thickness}
|
||||||
|
|
@ -69,7 +98,23 @@ export const Arc: React.FC<ArcProps> = ({
|
||||||
fill="none"
|
fill="none"
|
||||||
{...arcProps}
|
{...arcProps}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g opacity={opacity}>
|
||||||
|
{trackColor && (
|
||||||
|
<path
|
||||||
|
data-part="track"
|
||||||
|
d={describeArc(center, center, r, angleOffset, angleOffset + angleRange)}
|
||||||
|
stroke={trackColor}
|
||||||
|
strokeWidth={thickness}
|
||||||
|
strokeLinecap={cap}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
{valueArc}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,7 @@ export interface FaceProps extends React.SVGProps<SVGCircleElement> {
|
||||||
/** The knob body: a centered circle. Style via fill/stroke/filter props. */
|
/** The knob body: a centered circle. Style via fill/stroke/filter props. */
|
||||||
export const Face: React.FC<FaceProps> = ({ radius, ...rest }) => {
|
export const Face: React.FC<FaceProps> = ({ radius, ...rest }) => {
|
||||||
const { size, center } = useKnobContext();
|
const { size, center } = useKnobContext();
|
||||||
return <circle cx={center} cy={center} r={radius ?? size / 2 - 6} {...rest} />;
|
return (
|
||||||
|
<circle data-part="face" cx={center} cy={center} r={radius ?? size / 2 - 6} {...rest} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -69,5 +69,9 @@ export const Pointer: React.FC<PointerProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <g transform={`rotate(${angle} ${center} ${center})`}>{shape}</g>;
|
return (
|
||||||
|
<g data-part="pointer" transform={`rotate(${angle} ${center} ${center})`}>
|
||||||
|
{shape}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export const KnobValue: React.FC<KnobValueProps> = ({
|
||||||
}${unit ?? ''}`;
|
}${unit ?? ''}`;
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
|
data-part="value"
|
||||||
x={ctx.center}
|
x={ctx.center}
|
||||||
y={ctx.center + dy}
|
y={ctx.center + dy}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
|
|
@ -80,6 +81,7 @@ export const KnobLabel: React.FC<KnobLabelProps> = ({
|
||||||
const { size, center } = useKnobContext();
|
const { size, center } = useKnobContext();
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
|
data-part="label"
|
||||||
x={center}
|
x={center}
|
||||||
y={center + (dy ?? size * 0.38)}
|
y={center + (dy ?? size * 0.38)}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export const TickLabels: React.FC<TickLabelsProps> = ({
|
||||||
const { size, center: c, angleOffset, angleRange } = useKnobContext();
|
const { size, center: c, angleOffset, angleRange } = useKnobContext();
|
||||||
const r = radius ?? size / 2 - size * 0.02;
|
const r = radius ?? size / 2 - size * 0.02;
|
||||||
return (
|
return (
|
||||||
<g>
|
<g data-part="tick-labels">
|
||||||
{labels.map((text, i) => {
|
{labels.map((text, i) => {
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
const t = labels.length === 1 ? 0 : i / (labels.length - 1);
|
const t = labels.length === 1 ? 0 : i / (labels.length - 1);
|
||||||
|
|
|
||||||
|
|
@ -51,5 +51,5 @@ export const Ticks: React.FC<TicksProps> = ({
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <g>{ticks}</g>;
|
return <g data-part="ticks">{ticks}</g>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
breadth?: number;
|
breadth?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
trackColor?: string;
|
trackColor?: string;
|
||||||
|
/** Base color of the cap handle (e.g. '#e8e8ec' for an SSL-style white cap). */
|
||||||
|
capColor?: string;
|
||||||
|
/** Cap size along the travel axis in px. Default: 20. */
|
||||||
|
capLength?: number;
|
||||||
/** Fill the travelled part of the track. Default: true. */
|
/** Fill the travelled part of the track. Default: true. */
|
||||||
showFill?: boolean;
|
showFill?: boolean;
|
||||||
tickCount?: number;
|
tickCount?: number;
|
||||||
|
|
@ -33,13 +37,15 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Studio channel fader: absolute-position linear control with a cap handle. */
|
/** Studio channel fader: absolute-position linear control with a cap handle. */
|
||||||
export const Fader: React.FC<FaderProps> = ({
|
export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader({
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
focusRing,
|
focusRing,
|
||||||
length = 160,
|
length = 160,
|
||||||
breadth = 44,
|
breadth = 44,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
trackColor,
|
trackColor,
|
||||||
|
capColor,
|
||||||
|
capLength,
|
||||||
showFill = true,
|
showFill = true,
|
||||||
tickCount = 9,
|
tickCount = 9,
|
||||||
tickColor,
|
tickColor,
|
||||||
|
|
@ -54,7 +60,7 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const [editing, setEditing] = React.useState(false);
|
const [editing, setEditing] = React.useState(false);
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const color = colorProp ?? theme.accent ?? '#4cc2ff';
|
const color = colorProp ?? theme.accent ?? '#4cc2ff';
|
||||||
|
|
@ -62,7 +68,7 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
const resolvedTrack = trackColor ?? theme.track;
|
const resolvedTrack = trackColor ?? theme.track;
|
||||||
const resolvedTicks = tickColor ?? theme.ticks;
|
const resolvedTicks = tickColor ?? theme.ticks;
|
||||||
const vertical = orientation === 'vertical';
|
const vertical = orientation === 'vertical';
|
||||||
const capMain = 20; // handle size along the travel axis
|
const capMain = capLength ?? 20; // handle size along the travel axis
|
||||||
const knob = useKnob({
|
const knob = useKnob({
|
||||||
...core,
|
...core,
|
||||||
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
||||||
|
|
@ -167,6 +173,11 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
{...knob.bind}
|
{...knob.bind}
|
||||||
|
ref={node => {
|
||||||
|
(knob.bind.ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
if (typeof ref === 'function') ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
...knob.bind.style,
|
...knob.bind.style,
|
||||||
width: w,
|
width: w,
|
||||||
|
|
@ -178,6 +189,17 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
>
|
>
|
||||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
|
{capColor ? (
|
||||||
|
// Custom cap: solid base with a neutral shading overlay so any
|
||||||
|
// color still reads as a 3D handle.
|
||||||
|
<linearGradient id={`${id}-cap`} x1="0" y1="0" x2={vertical ? '0' : '1'} y2={vertical ? '1' : '0'}>
|
||||||
|
<stop offset="0" stopColor="#ffffff" stopOpacity="0.32" />
|
||||||
|
<stop offset="0.45" stopColor="#ffffff" stopOpacity="0.04" />
|
||||||
|
<stop offset="0.5" stopColor="#000000" stopOpacity="0.55" />
|
||||||
|
<stop offset="0.55" stopColor="#ffffff" stopOpacity="0.06" />
|
||||||
|
<stop offset="1" stopColor="#000000" stopOpacity="0.35" />
|
||||||
|
</linearGradient>
|
||||||
|
) : (
|
||||||
<linearGradient id={`${id}-cap`} x1="0" y1="0" x2={vertical ? '0' : '1'} y2={vertical ? '1' : '0'}>
|
<linearGradient id={`${id}-cap`} x1="0" y1="0" x2={vertical ? '0' : '1'} y2={vertical ? '1' : '0'}>
|
||||||
<stop offset="0" stopColor="#43444b" />
|
<stop offset="0" stopColor="#43444b" />
|
||||||
<stop offset="0.45" stopColor="#26272c" />
|
<stop offset="0.45" stopColor="#26272c" />
|
||||||
|
|
@ -185,6 +207,7 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
<stop offset="0.55" stopColor="#26272c" />
|
<stop offset="0.55" stopColor="#26272c" />
|
||||||
<stop offset="1" stopColor="#16171b" />
|
<stop offset="1" stopColor="#16171b" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
)}
|
||||||
</defs>
|
</defs>
|
||||||
{ticks}
|
{ticks}
|
||||||
{vertical ? (
|
{vertical ? (
|
||||||
|
|
@ -194,6 +217,9 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
<rect x={mid - trackW / 2} y={fillStart} width={trackW} height={fillLength} rx={trackW / 2} fill={color} />
|
<rect x={mid - trackW / 2} y={fillStart} width={trackW} height={fillLength} rx={trackW / 2} fill={color} />
|
||||||
)}
|
)}
|
||||||
<g style={{ transition: knob.isDragging ? undefined : 'transform 40ms linear' }}>
|
<g style={{ transition: knob.isDragging ? undefined : 'transform 40ms linear' }}>
|
||||||
|
{capColor && (
|
||||||
|
<rect x={mid - capCross / 2} y={pos - capMain / 2} width={capCross} height={capMain} rx={3} fill={capColor} />
|
||||||
|
)}
|
||||||
<rect
|
<rect
|
||||||
x={mid - capCross / 2}
|
x={mid - capCross / 2}
|
||||||
y={pos - capMain / 2}
|
y={pos - capMain / 2}
|
||||||
|
|
@ -213,6 +239,9 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
<rect x={fillStart} y={mid - trackW / 2} width={fillLength} height={trackW} rx={trackW / 2} fill={color} />
|
<rect x={fillStart} y={mid - trackW / 2} width={fillLength} height={trackW} rx={trackW / 2} fill={color} />
|
||||||
)}
|
)}
|
||||||
<g>
|
<g>
|
||||||
|
{capColor && (
|
||||||
|
<rect x={pos - capMain / 2} y={mid - capCross / 2} width={capMain} height={capCross} rx={3} fill={capColor} />
|
||||||
|
)}
|
||||||
<rect
|
<rect
|
||||||
x={pos - capMain / 2}
|
x={pos - capMain / 2}
|
||||||
y={mid - capCross / 2}
|
y={mid - capCross / 2}
|
||||||
|
|
@ -247,4 +276,4 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export interface FlatKnobProps extends SkinProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clean 2D knob — the modern DAW/plugin look. */
|
/** Clean 2D knob — the modern DAW/plugin look. */
|
||||||
export const FlatKnob: React.FC<FlatKnobProps> = ({
|
export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function FlatKnob({
|
||||||
size = 80,
|
size = 80,
|
||||||
color,
|
color,
|
||||||
trackColor,
|
trackColor,
|
||||||
|
|
@ -39,12 +39,13 @@ export const FlatKnob: React.FC<FlatKnobProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const accent = color ?? theme.accent ?? '#4cc2ff';
|
const accent = color ?? theme.accent ?? '#4cc2ff';
|
||||||
const t = arcThickness ?? Math.max(3, size * 0.055);
|
const t = arcThickness ?? Math.max(3, size * 0.055);
|
||||||
return (
|
return (
|
||||||
<Knob
|
<Knob
|
||||||
|
ref={ref}
|
||||||
size={size}
|
size={size}
|
||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={style}
|
||||||
|
|
@ -86,4 +87,4 @@ export const FlatKnob: React.FC<FlatKnobProps> = ({
|
||||||
)}
|
)}
|
||||||
</Knob>
|
</Knob>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export interface ImageKnobProps extends KnobCoreProps {
|
||||||
* Film-strip knob: renders one frame of a sprite strip per value position —
|
* Film-strip knob: renders one frame of a sprite strip per value position —
|
||||||
* the classic KnobMan / JUCE workflow for photoreal hardware knobs.
|
* the classic KnobMan / JUCE workflow for photoreal hardware knobs.
|
||||||
*/
|
*/
|
||||||
export const ImageKnob: React.FC<ImageKnobProps> = ({
|
export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(function ImageKnob({
|
||||||
src,
|
src,
|
||||||
frames,
|
frames,
|
||||||
stripOrientation = 'vertical',
|
stripOrientation = 'vertical',
|
||||||
|
|
@ -52,7 +52,7 @@ export const ImageKnob: React.FC<ImageKnobProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const knob = useKnob(core);
|
const knob = useKnob(core);
|
||||||
const [editing, setEditing] = React.useState(false);
|
const [editing, setEditing] = React.useState(false);
|
||||||
|
|
@ -85,6 +85,11 @@ export const ImageKnob: React.FC<ImageKnobProps> = ({
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
{...knob.bind}
|
{...knob.bind}
|
||||||
|
ref={node => {
|
||||||
|
(knob.bind.ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
if (typeof ref === 'function') ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
}}
|
||||||
onDoubleClick={
|
onDoubleClick={
|
||||||
editable
|
editable
|
||||||
? e => {
|
? e => {
|
||||||
|
|
@ -161,4 +166,4 @@ export const ImageKnob: React.FC<ImageKnobProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Segmented LED fader — an interactive meter-style linear control. */
|
/** Segmented LED fader — an interactive meter-style linear control. */
|
||||||
export const LEDFader: React.FC<LEDFaderProps> = ({
|
export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function LEDFader({
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
length = 160,
|
length = 160,
|
||||||
breadth = 34,
|
breadth = 34,
|
||||||
|
|
@ -71,7 +71,7 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const [editing, setEditing] = React.useState(false);
|
const [editing, setEditing] = React.useState(false);
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const color = colorProp ?? theme.accent ?? '#3df2ad';
|
const color = colorProp ?? theme.accent ?? '#3df2ad';
|
||||||
|
|
@ -189,6 +189,11 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
{...knob.bind}
|
{...knob.bind}
|
||||||
|
ref={node => {
|
||||||
|
(knob.bind.ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
if (typeof ref === 'function') ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
...knob.bind.style,
|
...knob.bind.style,
|
||||||
width: w,
|
width: w,
|
||||||
|
|
@ -246,4 +251,4 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ const CenterDisplay: React.FC<{
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Digital knob: segmented LED ring around a real seven-segment readout. */
|
/** Digital knob: segmented LED ring around a real seven-segment readout. */
|
||||||
export const LEDKnob: React.FC<LEDKnobProps> = ({
|
export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function LEDKnob({
|
||||||
size = 90,
|
size = 90,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
offColor = 'rgba(255,255,255,0.07)',
|
offColor = 'rgba(255,255,255,0.07)',
|
||||||
|
|
@ -96,13 +96,13 @@ export const LEDKnob: React.FC<LEDKnobProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const color = colorProp ?? theme.accent ?? '#3df2ad';
|
const color = colorProp ?? theme.accent ?? '#3df2ad';
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const glowId = `${id}-glow`;
|
const glowId = `${id}-glow`;
|
||||||
return (
|
return (
|
||||||
<Knob size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||||
{ctx => (
|
{ctx => (
|
||||||
<>
|
<>
|
||||||
<defs>
|
<defs>
|
||||||
|
|
@ -137,4 +137,4 @@ export const LEDKnob: React.FC<LEDKnobProps> = ({
|
||||||
)}
|
)}
|
||||||
</Knob>
|
</Knob>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export interface MetalKnobProps extends SkinProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 3D brushed-aluminum knob with a knurled rim — classic hi-fi hardware. */
|
/** 3D brushed-aluminum knob with a knurled rim — classic hi-fi hardware. */
|
||||||
export const MetalKnob: React.FC<MetalKnobProps> = ({
|
export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(function MetalKnob({
|
||||||
size = 90,
|
size = 90,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
tone = 'silver',
|
tone = 'silver',
|
||||||
|
|
@ -40,7 +40,7 @@ export const MetalKnob: React.FC<MetalKnobProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const color = colorProp ?? theme.accent ?? '#4cc2ff';
|
const color = colorProp ?? theme.accent ?? '#4cc2ff';
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
|
|
@ -61,7 +61,7 @@ export const MetalKnob: React.FC<MetalKnobProps> = ({
|
||||||
const indicator = indicatorColor ?? (silver ? '#1b1c21' : color);
|
const indicator = indicatorColor ?? (silver ? '#1b1c21' : color);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Knob size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={rimId} x1="0" y1="0" x2="0.8" y2="1">
|
<linearGradient id={rimId} x1="0" y1="0" x2="0.8" y2="1">
|
||||||
<stop offset="0" stopColor={rimStops[0]} />
|
<stop offset="0" stopColor={rimStops[0]} />
|
||||||
|
|
@ -144,4 +144,4 @@ export const MetalKnob: React.FC<MetalKnobProps> = ({
|
||||||
)}
|
)}
|
||||||
</Knob>
|
</Knob>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
181
packages/dreamknob/src/skins/Meter.tsx
Normal file
181
packages/dreamknob/src/skins/Meter.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { clamp, linearTaper } from '../core/math';
|
||||||
|
import type { Taper } from '../core/math';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { LEDFaderZone } from './LEDFader';
|
||||||
|
import { SegmentDisplay } from '../digital/SegmentDisplay';
|
||||||
|
|
||||||
|
export interface MeterProps {
|
||||||
|
/** Level to display. The meter is read-only — always controlled. */
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
taper?: Taper;
|
||||||
|
orientation?: 'vertical' | 'horizontal';
|
||||||
|
/** Travel length in px. Default: 160. */
|
||||||
|
length?: number;
|
||||||
|
/** Cross-axis size in px. Default: 16. */
|
||||||
|
breadth?: number;
|
||||||
|
/** Number of LED segments. Default: 28. */
|
||||||
|
segments?: number;
|
||||||
|
/** LED color when no zones are given. */
|
||||||
|
color?: string;
|
||||||
|
/** Meter zones, e.g. green / yellow / red by normalized position. */
|
||||||
|
zones?: readonly LEDFaderZone[];
|
||||||
|
/** Opacity of unlit segments. Default: 0.1. */
|
||||||
|
offOpacity?: number;
|
||||||
|
/**
|
||||||
|
* Peak hold time in ms — the highest recent segment stays lit and falls
|
||||||
|
* back after this long. `false` disables. Default: 1200.
|
||||||
|
*/
|
||||||
|
peakHold?: number | false;
|
||||||
|
/** Peak indicator color. Defaults to the peak's zone color. */
|
||||||
|
peakColor?: string;
|
||||||
|
/** Panel color behind the LEDs. */
|
||||||
|
faceColor?: string;
|
||||||
|
label?: string;
|
||||||
|
/** Show a seven-segment readout. Default: false. */
|
||||||
|
showValue?: boolean;
|
||||||
|
digits?: number;
|
||||||
|
displayDecimals?: number;
|
||||||
|
labelColor?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only LED level meter with peak hold — the display-side sibling of
|
||||||
|
* LEDFader for VU/output metering.
|
||||||
|
*/
|
||||||
|
export const Meter: React.FC<MeterProps> = ({
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
taper = linearTaper,
|
||||||
|
orientation = 'vertical',
|
||||||
|
length = 160,
|
||||||
|
breadth = 16,
|
||||||
|
segments = 28,
|
||||||
|
color = '#3df2ad',
|
||||||
|
zones,
|
||||||
|
offOpacity = 0.1,
|
||||||
|
peakHold = 1200,
|
||||||
|
peakColor,
|
||||||
|
faceColor = '#0b0d0e',
|
||||||
|
label,
|
||||||
|
showValue = false,
|
||||||
|
digits = 4,
|
||||||
|
displayDecimals = 1,
|
||||||
|
labelColor,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...aria
|
||||||
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const vertical = orientation === 'vertical';
|
||||||
|
const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1);
|
||||||
|
|
||||||
|
// Peak: track the highest recent level, fall back after `peakHold` ms.
|
||||||
|
const [peak, setPeak] = React.useState(n);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (peakHold === false) return;
|
||||||
|
if (n >= peak) {
|
||||||
|
setPeak(n);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = setTimeout(() => setPeak(n), peakHold);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [n, peak, peakHold]);
|
||||||
|
|
||||||
|
const pad = 4;
|
||||||
|
const w = vertical ? breadth : length;
|
||||||
|
const h = vertical ? length : breadth;
|
||||||
|
const zoneList: readonly LEDFaderZone[] = React.useMemo(
|
||||||
|
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
|
||||||
|
[zones, color],
|
||||||
|
);
|
||||||
|
const zoneFor = (t: number): string =>
|
||||||
|
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||||
|
|
||||||
|
const lit = Math.round(n * segments);
|
||||||
|
const peakIdx =
|
||||||
|
peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1;
|
||||||
|
const slot = (length - pad * 2) / segments;
|
||||||
|
const gap = Math.min(2.5, slot * 0.35);
|
||||||
|
const cross = breadth - pad * 2;
|
||||||
|
|
||||||
|
const leds: React.ReactNode[] = [];
|
||||||
|
for (let i = 0; i < segments; i++) {
|
||||||
|
const zone = zoneFor((i + 1) / segments);
|
||||||
|
const isPeak = i === peakIdx && i >= lit;
|
||||||
|
const on = i < lit || isPeak;
|
||||||
|
const fill = isPeak ? (peakColor ?? zone) : zone;
|
||||||
|
const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
|
||||||
|
leds.push(
|
||||||
|
vertical ? (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={pad}
|
||||||
|
y={length - pad - (i + 1) * slot + gap / 2}
|
||||||
|
width={cross}
|
||||||
|
height={slot - gap}
|
||||||
|
{...common}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<rect key={i} x={pad + i * slot + gap / 2} y={pad} width={slot - gap} height={cross} {...common} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
role="meter"
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={clamp(value, min, max)}
|
||||||
|
aria-label={aria['aria-label']}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
width: w,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showValue && (
|
||||||
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<SegmentDisplay
|
||||||
|
value={clamp(value, min, max)}
|
||||||
|
digits={digits}
|
||||||
|
decimals={displayDecimals}
|
||||||
|
height={11}
|
||||||
|
color={zoneFor(n)}
|
||||||
|
background="none"
|
||||||
|
ghostOpacity={0.06}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||||
|
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
|
||||||
|
{leds}
|
||||||
|
</svg>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: labelColor ?? theme.label,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -15,7 +15,7 @@ export interface NeonKnobProps extends SkinProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal glowing arc knob — futuristic / dark-mode DAW style. */
|
/** Minimal glowing arc knob — futuristic / dark-mode DAW style. */
|
||||||
export const NeonKnob: React.FC<NeonKnobProps> = ({
|
export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function NeonKnob({
|
||||||
size = 84,
|
size = 84,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
trackColor = 'rgba(255,255,255,0.06)',
|
trackColor = 'rgba(255,255,255,0.06)',
|
||||||
|
|
@ -30,13 +30,13 @@ export const NeonKnob: React.FC<NeonKnobProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const color = colorProp ?? theme.accent ?? '#e44cff';
|
const color = colorProp ?? theme.accent ?? '#e44cff';
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const glowId = `${id}-glow`;
|
const glowId = `${id}-glow`;
|
||||||
return (
|
return (
|
||||||
<Knob size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||||
<defs>
|
<defs>
|
||||||
<filter
|
<filter
|
||||||
id={glowId}
|
id={glowId}
|
||||||
|
|
@ -92,4 +92,4 @@ export const NeonKnob: React.FC<NeonKnobProps> = ({
|
||||||
)}
|
)}
|
||||||
</Knob>
|
</Knob>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export interface RubberKnobProps extends SkinProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft-touch rubber knob with a glowing halo — modern synth hardware. */
|
/** Soft-touch rubber knob with a glowing halo — modern synth hardware. */
|
||||||
export const RubberKnob: React.FC<RubberKnobProps> = ({
|
export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(function RubberKnob({
|
||||||
size = 90,
|
size = 90,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
trackColor,
|
trackColor,
|
||||||
|
|
@ -32,7 +32,7 @@ export const RubberKnob: React.FC<RubberKnobProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const color = colorProp ?? theme.accent ?? '#ff9640';
|
const color = colorProp ?? theme.accent ?? '#ff9640';
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
|
|
@ -42,7 +42,7 @@ export const RubberKnob: React.FC<RubberKnobProps> = ({
|
||||||
const bodyR = size / 2 - size * 0.17;
|
const bodyR = size / 2 - size * 0.17;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Knob size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id={bodyId} cx="0.38" cy="0.3" r="1">
|
<radialGradient id={bodyId} cx="0.38" cy="0.3" r="1">
|
||||||
<stop offset="0" stopColor="#3a3b41" />
|
<stop offset="0" stopColor="#3a3b41" />
|
||||||
|
|
@ -110,4 +110,4 @@ export const RubberKnob: React.FC<RubberKnobProps> = ({
|
||||||
)}
|
)}
|
||||||
</Knob>
|
</Knob>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export interface SteppedKnobProps extends SkinProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detented selector knob — mode/range switches with hard stops. */
|
/** Detented selector knob — mode/range switches with hard stops. */
|
||||||
export const SteppedKnob: React.FC<SteppedKnobProps> = ({
|
export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(function SteppedKnob({
|
||||||
size = 90,
|
size = 90,
|
||||||
positions,
|
positions,
|
||||||
steps,
|
steps,
|
||||||
|
|
@ -34,7 +34,7 @@ export const SteppedKnob: React.FC<SteppedKnobProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const color = colorProp ?? theme.accent ?? '#ffd23e';
|
const color = colorProp ?? theme.accent ?? '#ffd23e';
|
||||||
const detents = positions ? positions.length : steps ?? 5;
|
const detents = positions ? positions.length : steps ?? 5;
|
||||||
|
|
@ -43,6 +43,7 @@ export const SteppedKnob: React.FC<SteppedKnobProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Knob
|
<Knob
|
||||||
|
ref={ref}
|
||||||
size={size}
|
size={size}
|
||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={style}
|
||||||
|
|
@ -109,4 +110,4 @@ export const SteppedKnob: React.FC<SteppedKnobProps> = ({
|
||||||
}}
|
}}
|
||||||
</Knob>
|
</Knob>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ const ChickenHead: React.FC<{ bodyColor: string; indicatorColor: string }> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Vintage amp-style knob: chicken-head pointer over a printed tick scale. */
|
/** Vintage amp-style knob: chicken-head pointer over a printed tick scale. */
|
||||||
export const VintageKnob: React.FC<VintageKnobProps> = ({
|
export const VintageKnob = React.forwardRef<HTMLDivElement, VintageKnobProps>(function VintageKnob({
|
||||||
size = 96,
|
size = 96,
|
||||||
bodyColor = '#efe6d0',
|
bodyColor = '#efe6d0',
|
||||||
scaleColor,
|
scaleColor,
|
||||||
|
|
@ -107,11 +107,11 @@ export const VintageKnob: React.FC<VintageKnobProps> = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const scale = scaleColor ?? theme.ticks;
|
const scale = scaleColor ?? theme.ticks;
|
||||||
return (
|
return (
|
||||||
<Knob size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||||
<Ticks
|
<Ticks
|
||||||
count={11}
|
count={11}
|
||||||
radius={size / 2 - size * (scaleLabels ? 0.1 : 0.04)}
|
radius={size / 2 - size * (scaleLabels ? 0.1 : 0.04)}
|
||||||
|
|
@ -139,4 +139,4 @@ export const VintageKnob: React.FC<VintageKnobProps> = ({
|
||||||
)}
|
)}
|
||||||
</Knob>
|
</Knob>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ export interface SkinProps extends KnobCoreProps {
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
/** Custom parser for typed input when `editable` is set. */
|
/** Custom parser for typed input when `editable` is set. */
|
||||||
parseValue?: (text: string) => number | null;
|
parseValue?: (text: string) => number | null;
|
||||||
|
/** Render a hidden form input carrying the current value. */
|
||||||
|
name?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue