Tier 1: origin anchor, detents, theming, type-in editing, ImageKnob
- origin: bipolar anchor value — Arc from=center, Fader fill and LEDFader segments draw from origin instead of min/mid-travel - detents + detentSize: magnetic snap while dragging (normalized space, wheel/keyboard unaffected); applyDetents exported - DreamknobProvider + KnobTheme: accent/track/face/text/label/ticks/ focusRing/fonts tokens with dark and light bases; every skin resolves defaults from the theme, instance props win - editable + parseValue: double-click knobs (or click fader readouts) to type exact values; Enter/blur commits, Esc cancels, 1.2k parses; ValueInput + defaultParseValue exported; setValue added to context - ImageKnob: film-strip sprite knob (KnobMan-style strips), vertical or horizontal, with focus ring, editing, value/label - Docs: LED gallery cards for film-strip/bipolar/editing/theming, playground editable toggle, theming API section, README updates
This commit is contained in:
parent
d8992408fa
commit
804a85cca6
25 changed files with 999 additions and 162 deletions
|
|
@ -50,6 +50,8 @@ export const ApiDocs: React.FC = () => (
|
||||||
<Row name="values" type="number[]" desc="Restrict to a discrete list, e.g. [0.25, 0.5, 1, 2, 4]. Overrides step." />
|
<Row name="values" type="number[]" desc="Restrict to a discrete list, e.g. [0.25, 0.5, 1, 2, 4]. Overrides step." />
|
||||||
<Row name="steps" type="number" desc="Number of evenly spaced detents across the travel (selector knobs)." />
|
<Row name="steps" type="number" desc="Number of evenly spaced detents across the travel (selector knobs)." />
|
||||||
<Row name="taper" type="Taper" def="linearTaper" desc="Travel curve: linearTaper, logTaper (frequencies), powTaper(n) (gain), or your own." />
|
<Row name="taper" type="Taper" def="linearTaper" desc="Travel curve: linearTaper, logTaper (frequencies), powTaper(n) (gain), or your own." />
|
||||||
|
<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="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="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="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." />
|
||||||
|
|
@ -59,6 +61,8 @@ export const ApiDocs: React.FC = () => (
|
||||||
<Row name="onChange" type="(v: number) => void" desc="Fires for every distinct snapped value." />
|
<Row name="onChange" type="(v: number) => void" desc="Fires for every distinct snapped value." />
|
||||||
<Row name="onChangeStart / onChangeEnd" type="(v: number) => void" desc="Gesture boundaries — ideal for undo history or parameter automation." />
|
<Row name="onChangeStart / onChangeEnd" type="(v: number) => void" desc="Gesture boundaries — ideal for undo history or parameter automation." />
|
||||||
<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="focusRing" type="string | false" def="theme" desc="Keyboard focus ring color. Pointer grabs never show it." />
|
||||||
<Row name="aria-label / getAriaValueText" type="string / fn" desc="Accessibility. Every knob is a proper role='slider' with full keyboard support." />
|
<Row name="aria-label / getAriaValueText" type="string / fn" desc="Accessibility. Every knob is a proper role='slider' with full keyboard support." />
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|
@ -66,8 +70,10 @@ export const ApiDocs: React.FC = () => (
|
||||||
<p className="api-note">
|
<p className="api-note">
|
||||||
<code>↑/→</code> and <code>↓/←</code> step · <code>Shift</code>+arrows jump 10×
|
<code>↑/→</code> and <code>↓/←</code> step · <code>Shift</code>+arrows jump 10×
|
||||||
· <code>PageUp/PageDown</code> move 10% · <code>Home/End</code> jump to min/max ·
|
· <code>PageUp/PageDown</code> move 10% · <code>Home/End</code> jump to min/max ·
|
||||||
scroll wheel nudges (Shift = fine) · double-click resets · touch works via pointer
|
<code>Esc</code> cancels a drag and restores the start value · scroll wheel nudges
|
||||||
capture.
|
(Shift = fine) · double-click resets (or opens the editor when{' '}
|
||||||
|
<code>editable</code>) · touch works via pointer capture · every control shows a
|
||||||
|
keyboard-only focus ring.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="api-h">Prebuilt skins</h3>
|
<h3 className="api-h">Prebuilt skins</h3>
|
||||||
|
|
@ -81,6 +87,7 @@ export const ApiDocs: React.FC = () => (
|
||||||
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
|
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
|
||||||
<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="<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">
|
||||||
|
|
@ -89,6 +96,28 @@ export const ApiDocs: React.FC = () => (
|
||||||
above.
|
above.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3 className="api-h">Theming</h3>
|
||||||
|
<p className="api-note">
|
||||||
|
Every skin resolves its color and font defaults from the active theme; per-instance
|
||||||
|
props always win. Use <code>base="light"</code> for light backgrounds.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { DreamknobProvider } from 'dreamknob'
|
||||||
|
|
||||||
|
<DreamknobProvider
|
||||||
|
base="dark" // or "light"
|
||||||
|
theme={{
|
||||||
|
accent: '#ff4d6b', // one accent for the whole console
|
||||||
|
track: 'rgba(255,255,255,0.1)',
|
||||||
|
text: '#fff',
|
||||||
|
label: 'rgba(255,255,255,0.5)',
|
||||||
|
fontMono: '"IBM Plex Mono", monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlatKnob ... /> <Fader ... /> <LEDKnob ... />
|
||||||
|
</DreamknobProvider>`}
|
||||||
|
/>
|
||||||
|
|
||||||
<h3 className="api-h">Compose your own knob</h3>
|
<h3 className="api-h">Compose your own knob</h3>
|
||||||
<p className="api-note">
|
<p className="api-note">
|
||||||
<code><Knob></code> wires up the interaction and provides a render context;
|
<code><Knob></code> wires up the interaction and provides a render context;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Arc,
|
Arc,
|
||||||
|
DreamknobProvider,
|
||||||
Fader,
|
Fader,
|
||||||
FlatKnob,
|
FlatKnob,
|
||||||
|
ImageKnob,
|
||||||
Knob,
|
Knob,
|
||||||
KnobValue,
|
KnobValue,
|
||||||
LEDFader,
|
LEDFader,
|
||||||
|
|
@ -18,6 +20,60 @@ import {
|
||||||
logTaper,
|
logTaper,
|
||||||
} from 'dreamknob';
|
} from 'dreamknob';
|
||||||
|
|
||||||
|
/** Draw a film-strip of a simple hardware knob at runtime (stand-in for a KnobMan PNG). */
|
||||||
|
const useGeneratedStrip = (frames = 31, fs = 120): string | null => {
|
||||||
|
const [src, setSrc] = React.useState<string | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = fs;
|
||||||
|
canvas.height = fs * frames;
|
||||||
|
const g = canvas.getContext('2d');
|
||||||
|
if (!g) return;
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
const cx = fs / 2;
|
||||||
|
const cy = i * fs + fs / 2;
|
||||||
|
const angle = ((225 + (i / (frames - 1)) * 270) * Math.PI) / 180;
|
||||||
|
const grad = g.createRadialGradient(cx - fs * 0.15, cy - fs * 0.18, fs * 0.05, cx, cy, fs * 0.45);
|
||||||
|
grad.addColorStop(0, '#5c5f69');
|
||||||
|
grad.addColorStop(0.65, '#26282e');
|
||||||
|
grad.addColorStop(1, '#0e0f12');
|
||||||
|
g.fillStyle = grad;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(cx, cy, fs * 0.4, 0, Math.PI * 2);
|
||||||
|
g.fill();
|
||||||
|
g.strokeStyle = 'rgba(0,0,0,0.65)';
|
||||||
|
g.lineWidth = fs * 0.025;
|
||||||
|
g.stroke();
|
||||||
|
g.strokeStyle = '#ffd23e';
|
||||||
|
g.lineWidth = fs * 0.05;
|
||||||
|
g.lineCap = 'round';
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(cx + Math.sin(angle) * fs * 0.16, cy - Math.cos(angle) * fs * 0.16);
|
||||||
|
g.lineTo(cx + Math.sin(angle) * fs * 0.33, cy - Math.cos(angle) * fs * 0.33);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
setSrc(canvas.toDataURL('image/png'));
|
||||||
|
}, [frames, fs]);
|
||||||
|
return src;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImageKnobDemo: React.FC = () => {
|
||||||
|
const src = useGeneratedStrip();
|
||||||
|
if (!src) return null;
|
||||||
|
return (
|
||||||
|
<ImageKnob
|
||||||
|
src={src}
|
||||||
|
frames={31}
|
||||||
|
size={92}
|
||||||
|
defaultValue={40}
|
||||||
|
step={1}
|
||||||
|
showValue
|
||||||
|
label="Sprite"
|
||||||
|
aria-label="Film-strip demo"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Card: React.FC<{
|
const Card: React.FC<{
|
||||||
title: string;
|
title: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
|
|
@ -131,6 +187,88 @@ export const Gallery: React.FC = () => (
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Film-strip" desc="Photoreal sprite knobs — any KnobMan-style PNG strip works. This one is drawn at runtime." tag="<ImageKnob />">
|
||||||
|
<ImageKnobDemo />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Bipolar origin + detent" desc="Fill anchors at value 0 (not mid-travel) and the drag snaps magnetically at 0." tag="origin={0} detents={[0]}">
|
||||||
|
<FlatKnob
|
||||||
|
size={92}
|
||||||
|
min={-60}
|
||||||
|
max={12}
|
||||||
|
step={0.5}
|
||||||
|
defaultValue={0}
|
||||||
|
origin={0}
|
||||||
|
detents={[0]}
|
||||||
|
arcFrom="center"
|
||||||
|
color="#3df2ad"
|
||||||
|
format={v => `${v > 0 ? '+' : ''}${v.toFixed(1)}`}
|
||||||
|
label="Gain"
|
||||||
|
aria-label="Bipolar gain demo"
|
||||||
|
/>
|
||||||
|
<Fader
|
||||||
|
length={130}
|
||||||
|
min={-60}
|
||||||
|
max={12}
|
||||||
|
step={0.5}
|
||||||
|
defaultValue={0}
|
||||||
|
origin={0}
|
||||||
|
detents={[0]}
|
||||||
|
color="#3df2ad"
|
||||||
|
format={v => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
|
||||||
|
label="Trim"
|
||||||
|
aria-label="Bipolar fader demo"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Type-in editing" desc="Double-click the knob (or click a fader readout) and type the exact value — '1.2k' works too." tag="editable">
|
||||||
|
<FlatKnob
|
||||||
|
size={92}
|
||||||
|
min={20}
|
||||||
|
max={20000}
|
||||||
|
taper={logTaper}
|
||||||
|
decimals={0}
|
||||||
|
defaultValue={440}
|
||||||
|
editable
|
||||||
|
color="#4cc2ff"
|
||||||
|
format={v => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
|
||||||
|
label="Freq"
|
||||||
|
aria-label="Editable demo"
|
||||||
|
/>
|
||||||
|
<Fader
|
||||||
|
length={130}
|
||||||
|
min={-60}
|
||||||
|
max={12}
|
||||||
|
step={0.5}
|
||||||
|
defaultValue={-6}
|
||||||
|
editable
|
||||||
|
unit=" dB"
|
||||||
|
label="Out"
|
||||||
|
aria-label="Editable fader demo"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Theming" desc="One provider restyles every control — accent, tracks, text, fonts. Light backgrounds too." tag="<DreamknobProvider />">
|
||||||
|
<DreamknobProvider theme={{ accent: '#ff4d6b' }}>
|
||||||
|
<FlatKnob size={70} defaultValue={62} label="A" aria-label="Themed A" />
|
||||||
|
<RubberKnob size={70} defaultValue={35} label="B" aria-label="Themed B" />
|
||||||
|
</DreamknobProvider>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#eceef2',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '12px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DreamknobProvider base="light" theme={{ accent: '#0868c8' }}>
|
||||||
|
<FlatKnob size={70} defaultValue={62} label="Light" aria-label="Light themed" />
|
||||||
|
</DreamknobProvider>
|
||||||
|
</div>
|
||||||
|
</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" />
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ interface Config {
|
||||||
label: string;
|
label: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
showValue: boolean;
|
showValue: boolean;
|
||||||
|
editable: boolean;
|
||||||
arcFrom: 'min' | 'center';
|
arcFrom: 'min' | 'center';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,6 +68,7 @@ const DEFAULTS: Config = {
|
||||||
label: 'Level',
|
label: 'Level',
|
||||||
unit: '',
|
unit: '',
|
||||||
showValue: true,
|
showValue: true,
|
||||||
|
editable: false,
|
||||||
arcFrom: 'min',
|
arcFrom: 'min',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -86,6 +88,7 @@ const buildCode = (c: Config, controlled: boolean): string => {
|
||||||
props.push(`color="${c.color}"`);
|
props.push(`color="${c.color}"`);
|
||||||
if (c.label) props.push(`label="${c.label}"`);
|
if (c.label) props.push(`label="${c.label}"`);
|
||||||
if (c.unit) props.push(`unit="${c.unit}"`);
|
if (c.unit) props.push(`unit="${c.unit}"`);
|
||||||
|
if (c.editable) props.push('editable');
|
||||||
if (c.skin === 'flat' && c.arcFrom !== 'min') props.push(`arcFrom="center"`);
|
if (c.skin === 'flat' && c.arcFrom !== 'min') props.push(`arcFrom="center"`);
|
||||||
if (c.skin === 'stepped') props.push(`steps={5}`);
|
if (c.skin === 'stepped') props.push(`steps={5}`);
|
||||||
const name = SKINS[c.skin];
|
const name = SKINS[c.skin];
|
||||||
|
|
@ -124,6 +127,7 @@ export const Playground: React.FC = () => {
|
||||||
label: cfg.label || undefined,
|
label: cfg.label || undefined,
|
||||||
unit: cfg.unit || undefined,
|
unit: cfg.unit || undefined,
|
||||||
showValue: cfg.showValue,
|
showValue: cfg.showValue,
|
||||||
|
editable: cfg.editable,
|
||||||
'aria-label': cfg.label || 'playground knob',
|
'aria-label': cfg.label || 'playground knob',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -260,6 +264,16 @@ export const Playground: React.FC = () => {
|
||||||
show value readout
|
show value readout
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cfg.editable}
|
||||||
|
onChange={e => set('editable', e.target.checked)}
|
||||||
|
/>
|
||||||
|
type-in editing (dbl-click / click readout)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stage">
|
<div className="stage">
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ Every component shares one core:
|
||||||
| `↑ → / ↓ ←` | ± one step (`Shift` = 10×) |
|
| `↑ → / ↓ ←` | ± one step (`Shift` = 10×) |
|
||||||
| `PageUp/PageDown` | ± 10 % of range |
|
| `PageUp/PageDown` | ± 10 % of range |
|
||||||
| `Home/End` | min / max |
|
| `Home/End` | min / max |
|
||||||
| Double-click | reset to `defaultValue` |
|
| Double-click | reset to `defaultValue` — or open the type-in editor with `editable` |
|
||||||
|
| `Escape` | cancel an in-flight drag, restoring the start value |
|
||||||
| Touch / pen | pointer capture, `touch-action: none` |
|
| Touch / pen | pointer capture, `touch-action: none` |
|
||||||
|
|
||||||
All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
|
All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
|
||||||
|
|
@ -59,6 +60,10 @@ All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
|
||||||
- `steps={5}` — evenly spaced detents (selector knobs).
|
- `steps={5}` — evenly spaced detents (selector knobs).
|
||||||
- `taper` — `linearTaper` (default), `logTaper` (frequencies), `powTaper(n)` (gain),
|
- `taper` — `linearTaper` (default), `logTaper` (frequencies), `powTaper(n)` (gain),
|
||||||
or your own `{ toNormalized, fromNormalized }`.
|
or your own `{ toNormalized, fromNormalized }`.
|
||||||
|
- `origin={0}` — bipolar anchor: arcs/fader fills draw from this value (pan, gain trim).
|
||||||
|
- `detents={[0]}` + `detentSize` — magnetic snap points while dragging.
|
||||||
|
- `editable` — double-click (knobs) or click the readout (faders) to type an exact
|
||||||
|
value; `parseValue` customizes parsing (`"1.2k"` → 1200 out of the box).
|
||||||
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks.
|
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks.
|
||||||
|
|
||||||
## Prebuilt skins
|
## Prebuilt skins
|
||||||
|
|
@ -74,10 +79,24 @@ import {
|
||||||
SteppedKnob, // detented selector (positions={['LP','BP','HP']})
|
SteppedKnob, // detented selector (positions={['LP','BP','HP']})
|
||||||
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)
|
||||||
SegmentDisplay, // standalone seven-segment numeric display
|
SegmentDisplay, // standalone seven-segment numeric display
|
||||||
} from 'dreamknob'
|
} from 'dreamknob'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DreamknobProvider } from 'dreamknob'
|
||||||
|
|
||||||
|
<DreamknobProvider base="dark" theme={{ accent: '#ff4d6b' }}>
|
||||||
|
{/* every control below inherits the tokens; instance props still win */}
|
||||||
|
</DreamknobProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
|
||||||
|
`text`, `label`, `ticks`, `focusRing`, `fontMono`, `fontUI`.
|
||||||
|
|
||||||
Every skin takes the core props plus `size`, `label`, `showValue`, `unit`, `format`,
|
Every skin takes the core props plus `size`, `label`, `showValue`, `unit`, `format`,
|
||||||
and per-part color props (`color`, `trackColor`, `faceColor`, `bodyColor`, …).
|
and per-part color props (`color`, `trackColor`, `faceColor`, `bodyColor`, …).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { KnobContextProvider, type KnobRenderContext } from '../core/context';
|
import { KnobContextProvider, type KnobRenderContext } from '../core/context';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
import type { KnobCoreProps } from '../core/types';
|
import type { KnobCoreProps } from '../core/types';
|
||||||
import { useKnob } from '../hooks/useKnob';
|
import { useKnob } from '../hooks/useKnob';
|
||||||
|
import { ValueInput, defaultParseValue } from './ValueInput';
|
||||||
|
|
||||||
export interface KnobProps extends KnobCoreProps {
|
export interface KnobProps extends KnobCoreProps {
|
||||||
/** Square canvas size in px. Default: 80. */
|
/** Square canvas size in px. Default: 80. */
|
||||||
size?: number;
|
size?: number;
|
||||||
/**
|
/**
|
||||||
* Color of the keyboard focus ring, or `false` to disable it (only do this
|
* Color of the keyboard focus ring, or `false` to disable it (only do this
|
||||||
* if you render your own focus indicator). Default: a soft blue.
|
* if you render your own focus indicator). Default: the theme's ring.
|
||||||
*/
|
*/
|
||||||
focusRing?: string | false;
|
focusRing?: string | false;
|
||||||
|
/**
|
||||||
|
* Double-click opens a type-in editor instead of resetting: type an exact
|
||||||
|
* value, Enter/blur commits, Escape cancels. Accepts "1.2k" style input.
|
||||||
|
*/
|
||||||
|
editable?: boolean;
|
||||||
|
/** Custom parser for typed input, e.g. to accept "-6 dB" or "A4". */
|
||||||
|
parseValue?: (text: string) => number | null;
|
||||||
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. */
|
||||||
|
|
@ -26,17 +35,35 @@ export interface KnobProps extends KnobCoreProps {
|
||||||
*/
|
*/
|
||||||
export const Knob: React.FC<KnobProps> = ({
|
export const Knob: React.FC<KnobProps> = ({
|
||||||
size = 80,
|
size = 80,
|
||||||
focusRing = 'rgba(130,180,255,0.65)',
|
focusRing,
|
||||||
|
editable = false,
|
||||||
|
parseValue,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
overlay,
|
overlay,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const ring = focusRing ?? theme.focusRing;
|
||||||
const knob = useKnob(core);
|
const knob = useKnob(core);
|
||||||
|
const [editing, setEditing] = React.useState(false);
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
setEditing(false);
|
||||||
|
knob.ref.current?.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
const onDoubleClick = editable
|
||||||
|
? (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
: knob.bind.onDoubleClick;
|
||||||
|
|
||||||
const ctx: KnobRenderContext = {
|
const ctx: KnobRenderContext = {
|
||||||
value: knob.value,
|
value: knob.value,
|
||||||
normalized: knob.normalized,
|
normalized: knob.normalized,
|
||||||
|
originNormalized: knob.originNormalized,
|
||||||
angle: knob.angle,
|
angle: knob.angle,
|
||||||
isDragging: knob.isDragging,
|
isDragging: knob.isDragging,
|
||||||
min: knob.min,
|
min: knob.min,
|
||||||
|
|
@ -46,12 +73,14 @@ export const Knob: React.FC<KnobProps> = ({
|
||||||
angleRange: knob.angleRange,
|
angleRange: knob.angleRange,
|
||||||
size,
|
size,
|
||||||
center: size / 2,
|
center: size / 2,
|
||||||
|
setValue: knob.setValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KnobContextProvider value={ctx}>
|
<KnobContextProvider value={ctx}>
|
||||||
<div
|
<div
|
||||||
{...knob.bind}
|
{...knob.bind}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
className={className}
|
className={className}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
|
|
@ -59,9 +88,7 @@ export const Knob: React.FC<KnobProps> = ({
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
...(knob.isFocusVisible && focusRing
|
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||||
? { boxShadow: `0 0 0 2px ${focusRing}` }
|
|
||||||
: undefined),
|
|
||||||
...knob.bind.style,
|
...knob.bind.style,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
|
|
@ -76,6 +103,17 @@ export const Knob: React.FC<KnobProps> = ({
|
||||||
{typeof children === 'function' ? children(ctx) : children}
|
{typeof children === 'function' ? children(ctx) : children}
|
||||||
</svg>
|
</svg>
|
||||||
{typeof overlay === 'function' ? overlay(ctx) : overlay}
|
{typeof overlay === 'function' ? overlay(ctx) : overlay}
|
||||||
|
{editing && (
|
||||||
|
<ValueInput
|
||||||
|
initial={String(knob.value)}
|
||||||
|
onCommit={raw => {
|
||||||
|
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||||
|
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
||||||
|
closeEditor();
|
||||||
|
}}
|
||||||
|
onCancel={closeEditor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</KnobContextProvider>
|
</KnobContextProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
88
packages/dreamknob/src/components/ValueInput.tsx
Normal file
88
packages/dreamknob/src/components/ValueInput.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a typed value: accepts "42", "-6.5", ".5", comma decimals ("3,5"),
|
||||||
|
* a k-suffix ("1.2k" -> 1200), and ignores trailing units ("440 Hz").
|
||||||
|
* Returns null when no number can be read.
|
||||||
|
*/
|
||||||
|
export const defaultParseValue = (s: string): number | null => {
|
||||||
|
const m = s
|
||||||
|
.trim()
|
||||||
|
.replace(',', '.')
|
||||||
|
.match(/^([-+]?(?:\d+\.?\d*|\.\d+))\s*([kK])?/);
|
||||||
|
if (!m) return null;
|
||||||
|
const n = parseFloat(m[1]) * (m[2] ? 1000 : 1);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ValueInputProps {
|
||||||
|
initial: string;
|
||||||
|
onCommit: (raw: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type-in editor: commits on Enter or blur, cancels on Escape. Stops
|
||||||
|
* pointer/keyboard events from reaching the knob underneath.
|
||||||
|
*/
|
||||||
|
export const ValueInput: React.FC<ValueInputProps> = ({
|
||||||
|
initial,
|
||||||
|
onCommit,
|
||||||
|
onCancel,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const ref = React.useRef<HTMLInputElement>(null);
|
||||||
|
const settled = React.useRef(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
ref.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commit = (raw: string) => {
|
||||||
|
if (settled.current) return;
|
||||||
|
settled.current = true;
|
||||||
|
onCommit(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
autoFocus
|
||||||
|
defaultValue={initial}
|
||||||
|
aria-label="Enter value"
|
||||||
|
onPointerDown={e => e.stopPropagation()}
|
||||||
|
onDoubleClick={e => e.stopPropagation()}
|
||||||
|
onKeyDown={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter') commit(e.currentTarget.value);
|
||||||
|
else if (e.key === 'Escape') {
|
||||||
|
settled.current = true;
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={e => commit(e.currentTarget.value)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: '72%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
background: 'rgba(8, 9, 12, 0.92)',
|
||||||
|
color: theme.text,
|
||||||
|
border: `1px solid ${theme.focusRing}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '3px 6px',
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
fontSize: 13,
|
||||||
|
textAlign: 'center',
|
||||||
|
outline: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,8 @@ export interface KnobRenderContext extends KnobState {
|
||||||
size: number;
|
size: number;
|
||||||
/** Center coordinate (size / 2). */
|
/** Center coordinate (size / 2). */
|
||||||
center: number;
|
center: number;
|
||||||
|
/** Imperatively set the value (snapped + clamped) — for editors and custom UI. */
|
||||||
|
setValue: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Ctx = React.createContext<KnobRenderContext | null>(null);
|
const Ctx = React.createContext<KnobRenderContext | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
angleFromNormalized,
|
angleFromNormalized,
|
||||||
angleFromPoint,
|
angleFromPoint,
|
||||||
|
applyDetents,
|
||||||
clamp,
|
clamp,
|
||||||
decimalsFromStep,
|
decimalsFromStep,
|
||||||
describeArc,
|
describeArc,
|
||||||
|
|
@ -128,6 +129,22 @@ describe('angles', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('applyDetents', () => {
|
||||||
|
it('snaps within the radius and releases outside it', () => {
|
||||||
|
expect(applyDetents(0.51, [0.5], 0.025)).toBe(0.5);
|
||||||
|
expect(applyDetents(0.49, [0.5], 0.025)).toBe(0.5);
|
||||||
|
expect(applyDetents(0.54, [0.5], 0.025)).toBe(0.54);
|
||||||
|
expect(applyDetents(0.2, [0.5], 0.025)).toBe(0.2);
|
||||||
|
});
|
||||||
|
it('picks the nearest of several detents', () => {
|
||||||
|
expect(applyDetents(0.26, [0.25, 0.5, 0.75], 0.05)).toBe(0.25);
|
||||||
|
expect(applyDetents(0.72, [0.25, 0.5, 0.75], 0.05)).toBe(0.75);
|
||||||
|
});
|
||||||
|
it('passes through with no detents', () => {
|
||||||
|
expect(applyDetents(0.42, [], 0.05)).toBe(0.42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('describeArc', () => {
|
describe('describeArc', () => {
|
||||||
it('produces a drawable path', () => {
|
it('produces a drawable path', () => {
|
||||||
const d = describeArc(50, 50, 40, 225, 495);
|
const d = describeArc(50, 50, 40, 225, 495);
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,27 @@ export const findClosest = (values: readonly number[], value: number): number =>
|
||||||
return best;
|
return best;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magnetic detents: snap `n` to the nearest of `detents` (all in normalized
|
||||||
|
* [0, 1] space) when within `radius` of it; otherwise pass `n` through.
|
||||||
|
*/
|
||||||
|
export const applyDetents = (
|
||||||
|
n: number,
|
||||||
|
detents: readonly number[],
|
||||||
|
radius: number,
|
||||||
|
): number => {
|
||||||
|
let best = n;
|
||||||
|
let bestDelta = radius;
|
||||||
|
for (const d of detents) {
|
||||||
|
const delta = Math.abs(n - d);
|
||||||
|
if (delta <= bestDelta) {
|
||||||
|
best = d;
|
||||||
|
bestDelta = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tapers — map between value space and the normalized [0, 1] travel of the
|
// Tapers — map between value space and the normalized [0, 1] travel of the
|
||||||
// control. Audio parameters are often logarithmic (frequency) or power-law
|
// control. Audio parameters are often logarithmic (frequency) or power-law
|
||||||
|
|
|
||||||
81
packages/dreamknob/src/core/theme.tsx
Normal file
81
packages/dreamknob/src/core/theme.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export const MONO_FONT =
|
||||||
|
"'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
||||||
|
export const UI_FONT =
|
||||||
|
"'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared design tokens. Every prebuilt skin resolves its color/font defaults
|
||||||
|
* from the active theme, so a single provider can restyle a whole console;
|
||||||
|
* per-instance props always win over the theme.
|
||||||
|
*/
|
||||||
|
export interface KnobTheme {
|
||||||
|
/**
|
||||||
|
* Accent for value arcs, pointers and LEDs. Optional — when unset, each
|
||||||
|
* skin keeps its own signature default color.
|
||||||
|
*/
|
||||||
|
accent?: string;
|
||||||
|
/** Background tracks and rails. */
|
||||||
|
track: string;
|
||||||
|
/** Knob face/body fill for flat skins. */
|
||||||
|
face: string;
|
||||||
|
/** Value readouts. */
|
||||||
|
text: string;
|
||||||
|
/** Captions / parameter names. */
|
||||||
|
label: string;
|
||||||
|
/** Inactive tick marks and printed scales. */
|
||||||
|
ticks: string;
|
||||||
|
/** Keyboard focus ring. */
|
||||||
|
focusRing: string;
|
||||||
|
fontMono: string;
|
||||||
|
fontUI: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const darkTheme: KnobTheme = {
|
||||||
|
track: 'rgba(255,255,255,0.12)',
|
||||||
|
face: 'rgba(255,255,255,0.05)',
|
||||||
|
text: 'rgba(255,255,255,0.92)',
|
||||||
|
label: 'rgba(255,255,255,0.45)',
|
||||||
|
ticks: 'rgba(255,255,255,0.18)',
|
||||||
|
focusRing: 'rgba(130,180,255,0.65)',
|
||||||
|
fontMono: MONO_FONT,
|
||||||
|
fontUI: UI_FONT,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTheme: KnobTheme = {
|
||||||
|
track: 'rgba(0,0,0,0.14)',
|
||||||
|
face: 'rgba(0,0,0,0.06)',
|
||||||
|
text: 'rgba(0,0,0,0.85)',
|
||||||
|
label: 'rgba(0,0,0,0.5)',
|
||||||
|
ticks: 'rgba(0,0,0,0.28)',
|
||||||
|
focusRing: 'rgba(0,110,220,0.55)',
|
||||||
|
fontMono: MONO_FONT,
|
||||||
|
fontUI: UI_FONT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeCtx = React.createContext<KnobTheme>(darkTheme);
|
||||||
|
|
||||||
|
export interface DreamknobProviderProps {
|
||||||
|
/** Base token set to start from. Default: 'dark'. */
|
||||||
|
base?: 'dark' | 'light';
|
||||||
|
/** Token overrides merged over the base. */
|
||||||
|
theme?: Partial<KnobTheme>;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provides shared design tokens to every dreamknob control below it. */
|
||||||
|
export const DreamknobProvider: React.FC<DreamknobProviderProps> = ({
|
||||||
|
base = 'dark',
|
||||||
|
theme,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const value = React.useMemo<KnobTheme>(
|
||||||
|
() => ({ ...(base === 'light' ? lightTheme : darkTheme), ...theme }),
|
||||||
|
[base, theme],
|
||||||
|
);
|
||||||
|
return <ThemeCtx.Provider value={value}>{children}</ThemeCtx.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The active theme (dark tokens when no provider is present). */
|
||||||
|
export const useKnobTheme = (): KnobTheme => React.useContext(ThemeCtx);
|
||||||
|
|
@ -37,6 +37,20 @@ export interface KnobCoreProps {
|
||||||
steps?: number;
|
steps?: number;
|
||||||
/** Travel-to-value curve. Defaults to linear. Use `logTaper` / `powTaper(n)` for audio params. */
|
/** Travel-to-value curve. Defaults to linear. Use `logTaper` / `powTaper(n)` for audio params. */
|
||||||
taper?: Taper;
|
taper?: Taper;
|
||||||
|
/**
|
||||||
|
* Anchor value for bipolar controls: arcs and fader fills draw from here
|
||||||
|
* instead of from the minimum (e.g. `origin={0}` on a -60..+12 dB gain or a
|
||||||
|
* -50..50 pan). Exposed to custom skins as `originNormalized`.
|
||||||
|
*/
|
||||||
|
origin?: number;
|
||||||
|
/**
|
||||||
|
* Magnetic detent values: while dragging, the control snaps to the nearest
|
||||||
|
* of these when within `detentSize` of it (e.g. `detents={[0]}` for a
|
||||||
|
* center-detented pan). Wheel and keyboard are unaffected.
|
||||||
|
*/
|
||||||
|
detents?: readonly number[];
|
||||||
|
/** Detent capture radius as a fraction of travel. Default: 0.025. */
|
||||||
|
detentSize?: number;
|
||||||
/** Drag behaviour. Default: 'rotary'. */
|
/** Drag behaviour. Default: 'rotary'. */
|
||||||
interaction?: InteractionMode;
|
interaction?: InteractionMode;
|
||||||
/** 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. */
|
||||||
|
|
@ -77,6 +91,8 @@ export interface KnobState {
|
||||||
value: number;
|
value: number;
|
||||||
/** Normalized travel position in [0, 1] (taper space). */
|
/** Normalized travel position in [0, 1] (taper space). */
|
||||||
normalized: number;
|
normalized: number;
|
||||||
|
/** Normalized position of `origin`, when set — the bipolar fill anchor. */
|
||||||
|
originNormalized?: number;
|
||||||
/** Display angle in degrees clockwise from 12 o'clock. */
|
/** Display angle in degrees clockwise from 12 o'clock. */
|
||||||
angle: number;
|
angle: number;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
angleFromNormalized,
|
angleFromNormalized,
|
||||||
angleFromPoint,
|
angleFromPoint,
|
||||||
|
applyDetents,
|
||||||
clamp,
|
clamp,
|
||||||
decimalsFromStep,
|
decimalsFromStep,
|
||||||
findClosest,
|
findClosest,
|
||||||
|
|
@ -75,6 +76,9 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
interaction = 'rotary',
|
interaction = 'rotary',
|
||||||
dragSensitivity = 200,
|
dragSensitivity = 200,
|
||||||
trackInset = 0,
|
trackInset = 0,
|
||||||
|
origin,
|
||||||
|
detents,
|
||||||
|
detentSize = 0.025,
|
||||||
fineMultiplier = 0.1,
|
fineMultiplier = 0.1,
|
||||||
angleOffset = 225,
|
angleOffset = 225,
|
||||||
angleRange = 270,
|
angleRange = 270,
|
||||||
|
|
@ -153,6 +157,18 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
[emit, taper, min, max],
|
[emit, taper, min, max],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Detent positions in normalized space, applied only during pointer drags.
|
||||||
|
const detentsN = React.useMemo(
|
||||||
|
() =>
|
||||||
|
detents?.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1)),
|
||||||
|
[detents, taper, min, max],
|
||||||
|
);
|
||||||
|
const setFromDrag = React.useCallback(
|
||||||
|
(n: number) =>
|
||||||
|
setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n),
|
||||||
|
[setFromNormalized, detentsN, detentSize],
|
||||||
|
);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Pointer dragging
|
// Pointer dragging
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -166,19 +182,19 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
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);
|
||||||
setFromNormalized(normalizedFromAngle(angle, angleOffset % 360, angleRange));
|
setFromDrag(normalizedFromAngle(angle, angleOffset % 360, angleRange));
|
||||||
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;
|
||||||
setFromNormalized((s.rect.bottom - trackInset - clientY) / travel);
|
setFromDrag((s.rect.bottom - trackInset - clientY) / travel);
|
||||||
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;
|
||||||
setFromNormalized((clientX - s.rect.left - trackInset) / travel);
|
setFromDrag((clientX - s.rect.left - trackInset) / travel);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|
@ -188,14 +204,15 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
dn += (s.lastY - clientY) * scale;
|
dn += (s.lastY - clientY) * scale;
|
||||||
if (interaction === 'horizontal' || interaction === 'both')
|
if (interaction === 'horizontal' || interaction === 'both')
|
||||||
dn += (clientX - s.lastX) * scale;
|
dn += (clientX - s.lastX) * scale;
|
||||||
|
// s.n stays continuous so magnetic detents release cleanly.
|
||||||
s.n = clamp(s.n + dn, 0, 1);
|
s.n = clamp(s.n + dn, 0, 1);
|
||||||
setFromNormalized(s.n);
|
setFromDrag(s.n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.lastX = clientX;
|
s.lastX = clientX;
|
||||||
s.lastY = clientY;
|
s.lastY = clientY;
|
||||||
},
|
},
|
||||||
[interaction, angleOffset, angleRange, dragSensitivity, trackInset, fineMultiplier, setFromNormalized],
|
[interaction, angleOffset, angleRange, dragSensitivity, trackInset, fineMultiplier, setFromDrag],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerDown = React.useCallback(
|
const onPointerDown = React.useCallback(
|
||||||
|
|
@ -409,10 +426,15 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
|
|
||||||
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
|
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
|
||||||
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
||||||
|
const originNormalized =
|
||||||
|
origin === undefined
|
||||||
|
? undefined
|
||||||
|
: clamp(taper.toNormalized(clamp(origin, min, max), min, max), 0, 1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value,
|
value,
|
||||||
normalized,
|
normalized,
|
||||||
|
originNormalized,
|
||||||
angle,
|
angle,
|
||||||
isDragging,
|
isDragging,
|
||||||
isFocusVisible,
|
isFocusVisible,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,16 @@
|
||||||
export { useKnob, type UseKnobResult } from './hooks/useKnob';
|
export { useKnob, type UseKnobResult } from './hooks/useKnob';
|
||||||
export { Knob, type KnobProps } from './components/Knob';
|
export { Knob, type KnobProps } from './components/Knob';
|
||||||
export { useKnobContext, type KnobRenderContext } from './core/context';
|
export { useKnobContext, type KnobRenderContext } from './core/context';
|
||||||
|
export {
|
||||||
|
DreamknobProvider,
|
||||||
|
useKnobTheme,
|
||||||
|
darkTheme,
|
||||||
|
lightTheme,
|
||||||
|
MONO_FONT,
|
||||||
|
UI_FONT,
|
||||||
|
type KnobTheme,
|
||||||
|
type DreamknobProviderProps,
|
||||||
|
} from './core/theme';
|
||||||
export type { KnobCoreProps, KnobState, InteractionMode } from './core/types';
|
export type { KnobCoreProps, KnobState, InteractionMode } from './core/types';
|
||||||
export {
|
export {
|
||||||
clamp,
|
clamp,
|
||||||
|
|
@ -9,6 +19,7 @@ export {
|
||||||
snapToStep,
|
snapToStep,
|
||||||
decimalsFromStep,
|
decimalsFromStep,
|
||||||
findClosest,
|
findClosest,
|
||||||
|
applyDetents,
|
||||||
linearTaper,
|
linearTaper,
|
||||||
logTaper,
|
logTaper,
|
||||||
powTaper,
|
powTaper,
|
||||||
|
|
@ -31,6 +42,9 @@ export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from '
|
||||||
// Digital
|
// Digital
|
||||||
export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay';
|
export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay';
|
||||||
|
|
||||||
|
// Value editing
|
||||||
|
export { defaultParseValue, ValueInput, type ValueInputProps } from './components/ValueInput';
|
||||||
|
|
||||||
// Prebuilt skins
|
// Prebuilt skins
|
||||||
export type { SkinProps } from './skins/shared';
|
export type { SkinProps } from './skins/shared';
|
||||||
export { FlatKnob, type FlatKnobProps } from './skins/FlatKnob';
|
export { FlatKnob, type FlatKnobProps } from './skins/FlatKnob';
|
||||||
|
|
@ -42,4 +56,5 @@ export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob';
|
||||||
export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
|
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 { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ export interface ArcProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Background track color. Omit to skip the track. */
|
/** Background track color. Omit to skip the track. */
|
||||||
trackColor?: string;
|
trackColor?: string;
|
||||||
/** Draw the value arc from the minimum ('min') or from the travel center ('center', pan-style). */
|
/**
|
||||||
|
* Draw the value arc from the minimum ('min') or from the anchor ('center',
|
||||||
|
* pan-style). The anchor is the knob's `origin` value when set, otherwise
|
||||||
|
* the travel midpoint.
|
||||||
|
*/
|
||||||
from?: 'min' | 'center';
|
from?: 'min' | 'center';
|
||||||
cap?: 'butt' | 'round';
|
cap?: 'butt' | 'round';
|
||||||
/** Extra SVG props for the value arc path (e.g. filter for glow). */
|
/** Extra SVG props for the value arc path (e.g. filter for glow). */
|
||||||
|
|
@ -29,16 +33,17 @@ export const Arc: React.FC<ArcProps> = ({
|
||||||
arcProps,
|
arcProps,
|
||||||
opacity,
|
opacity,
|
||||||
}) => {
|
}) => {
|
||||||
const { size, center, normalized, angleOffset, angleRange } = useKnobContext();
|
const { size, center, normalized, originNormalized, angleOffset, angleRange } =
|
||||||
|
useKnobContext();
|
||||||
const r = radius ?? (size - thickness) / 2;
|
const r = radius ?? (size - thickness) / 2;
|
||||||
|
|
||||||
let start: number;
|
let start: number;
|
||||||
let end: number;
|
let end: number;
|
||||||
if (from === 'center') {
|
if (from === 'center') {
|
||||||
const mid = angleOffset + angleRange / 2;
|
const anchor = angleOffset + (originNormalized ?? 0.5) * angleRange;
|
||||||
const now = angleOffset + normalized * angleRange;
|
const now = angleOffset + normalized * angleRange;
|
||||||
start = Math.min(mid, now);
|
start = Math.min(anchor, now);
|
||||||
end = Math.max(mid, now);
|
end = Math.max(anchor, now);
|
||||||
} else {
|
} else {
|
||||||
start = angleOffset;
|
start = angleOffset;
|
||||||
end = angleOffset + normalized * angleRange;
|
end = angleOffset + normalized * angleRange;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useKnob } from '../hooks/useKnob';
|
import { useKnob } from '../hooks/useKnob';
|
||||||
import type { KnobCoreProps } from '../core/types';
|
import type { KnobCoreProps } from '../core/types';
|
||||||
import { MONO_FONT, UI_FONT } from './shared';
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import { ValueInput, defaultParseValue } from '../components/ValueInput';
|
||||||
|
|
||||||
export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
orientation?: 'vertical' | 'horizontal';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
|
|
@ -23,6 +24,10 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
format?: (value: number) => string;
|
format?: (value: number) => string;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
|
/** Click the readout to type an exact value. */
|
||||||
|
editable?: boolean;
|
||||||
|
/** Custom parser for typed input. */
|
||||||
|
parseValue?: (text: string) => number | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
@ -30,24 +35,32 @@ 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.FC<FaderProps> = ({
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
focusRing = 'rgba(130,180,255,0.65)',
|
focusRing,
|
||||||
length = 160,
|
length = 160,
|
||||||
breadth = 44,
|
breadth = 44,
|
||||||
color = '#4cc2ff',
|
color: colorProp,
|
||||||
trackColor = 'rgba(255,255,255,0.12)',
|
trackColor,
|
||||||
showFill = true,
|
showFill = true,
|
||||||
tickCount = 9,
|
tickCount = 9,
|
||||||
tickColor = 'rgba(255,255,255,0.15)',
|
tickColor,
|
||||||
label,
|
label,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
unit,
|
unit,
|
||||||
format,
|
format,
|
||||||
textColor = 'rgba(255,255,255,0.92)',
|
textColor,
|
||||||
labelColor = 'rgba(255,255,255,0.45)',
|
labelColor,
|
||||||
|
editable = false,
|
||||||
|
parseValue,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
|
const [editing, setEditing] = React.useState(false);
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const color = colorProp ?? theme.accent ?? '#4cc2ff';
|
||||||
|
const ring = focusRing ?? theme.focusRing;
|
||||||
|
const resolvedTrack = trackColor ?? theme.track;
|
||||||
|
const resolvedTicks = tickColor ?? theme.ticks;
|
||||||
const vertical = orientation === 'vertical';
|
const vertical = orientation === 'vertical';
|
||||||
const capMain = 20; // handle size along the travel axis
|
const capMain = 20; // handle size along the travel axis
|
||||||
const knob = useKnob({
|
const knob = useKnob({
|
||||||
|
|
@ -67,9 +80,13 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
|
|
||||||
// Handle center position along the travel axis.
|
// Handle center position along the travel axis.
|
||||||
const travel = length - capMain;
|
const travel = length - capMain;
|
||||||
const pos = vertical
|
const toPos = (n: number) =>
|
||||||
? length - capMain / 2 - knob.normalized * travel
|
vertical ? length - capMain / 2 - n * travel : capMain / 2 + n * travel;
|
||||||
: capMain / 2 + knob.normalized * travel;
|
const pos = toPos(knob.normalized);
|
||||||
|
// Bipolar controls fill from the origin; unipolar from the start of travel.
|
||||||
|
const originPos = toPos(knob.originNormalized ?? 0);
|
||||||
|
const fillStart = Math.min(pos, originPos);
|
||||||
|
const fillLength = Math.abs(pos - originPos);
|
||||||
|
|
||||||
const fixed = knob.value.toFixed(knob.decimals);
|
const fixed = knob.value.toFixed(knob.decimals);
|
||||||
const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
|
const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
|
||||||
|
|
@ -82,13 +99,13 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
ticks.push(
|
ticks.push(
|
||||||
vertical ? (
|
vertical ? (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
<line x1={mid - breadth * 0.36} y1={p} x2={mid - breadth * 0.24} y2={p} stroke={tickColor} strokeWidth="1.4" />
|
<line x1={mid - breadth * 0.36} y1={p} x2={mid - breadth * 0.24} y2={p} stroke={resolvedTicks} strokeWidth="1.4" />
|
||||||
<line x1={mid + breadth * 0.24} y1={p} x2={mid + breadth * 0.36} y2={p} stroke={tickColor} strokeWidth="1.4" />
|
<line x1={mid + breadth * 0.24} y1={p} x2={mid + breadth * 0.36} y2={p} stroke={resolvedTicks} strokeWidth="1.4" />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
) : (
|
) : (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
<line x1={p} y1={mid - breadth * 0.36} x2={p} y2={mid - breadth * 0.24} stroke={tickColor} strokeWidth="1.4" />
|
<line x1={p} y1={mid - breadth * 0.36} x2={p} y2={mid - breadth * 0.24} stroke={resolvedTicks} strokeWidth="1.4" />
|
||||||
<line x1={p} y1={mid + breadth * 0.24} x2={p} y2={mid + breadth * 0.36} stroke={tickColor} strokeWidth="1.4" />
|
<line x1={p} y1={mid + breadth * 0.24} x2={p} y2={mid + breadth * 0.36} stroke={resolvedTicks} strokeWidth="1.4" />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -109,19 +126,43 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showValue && (
|
{showValue && (
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
fontFamily: MONO_FONT,
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: '1.4em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<ValueInput
|
||||||
|
initial={String(knob.value)}
|
||||||
|
onCommit={raw => {
|
||||||
|
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||||
|
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditing(false)}
|
||||||
|
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
onClick={editable ? () => setEditing(true) : undefined}
|
||||||
|
title={editable ? 'Click to type a value' : undefined}
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: textColor,
|
color: textColor ?? theme.text,
|
||||||
minHeight: '1.2em',
|
minHeight: '1.2em',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
pointerEvents: 'none',
|
pointerEvents: editable ? 'auto' : 'none',
|
||||||
|
cursor: editable ? 'text' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|
@ -132,9 +173,7 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
height: h,
|
height: h,
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
...(knob.isFocusVisible && focusRing
|
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||||
? { boxShadow: `0 0 0 2px ${focusRing}` }
|
|
||||||
: undefined),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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">
|
||||||
|
|
@ -150,9 +189,9 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
{ticks}
|
{ticks}
|
||||||
{vertical ? (
|
{vertical ? (
|
||||||
<>
|
<>
|
||||||
<rect x={mid - trackW / 2} y={capMain / 2} width={trackW} height={travel} rx={trackW / 2} fill={trackColor} />
|
<rect x={mid - trackW / 2} y={capMain / 2} width={trackW} height={travel} rx={trackW / 2} fill={resolvedTrack} />
|
||||||
{showFill && (
|
{showFill && fillLength > 0 && (
|
||||||
<rect x={mid - trackW / 2} y={pos} width={trackW} height={length - capMain / 2 - pos} 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' }}>
|
||||||
<rect
|
<rect
|
||||||
|
|
@ -169,9 +208,9 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<rect x={capMain / 2} y={mid - trackW / 2} width={travel} height={trackW} rx={trackW / 2} fill={trackColor} />
|
<rect x={capMain / 2} y={mid - trackW / 2} width={travel} height={trackW} rx={trackW / 2} fill={resolvedTrack} />
|
||||||
{showFill && (
|
{showFill && fillLength > 0 && (
|
||||||
<rect x={capMain / 2} y={mid - trackW / 2} width={pos - capMain / 2} 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>
|
||||||
<rect
|
<rect
|
||||||
|
|
@ -193,11 +232,11 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: UI_FONT,
|
fontFamily: theme.fontUI,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.08em',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: labelColor,
|
color: labelColor ?? theme.label,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Knob } from '../components/Knob';
|
import { Knob } from '../components/Knob';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
import { Arc } from '../primitives/Arc';
|
import { Arc } from '../primitives/Arc';
|
||||||
import { Face } from '../primitives/Face';
|
import { Face } from '../primitives/Face';
|
||||||
import { Pointer } from '../primitives/Pointer';
|
import { Pointer } from '../primitives/Pointer';
|
||||||
import { KnobLabel, KnobValue } from '../primitives/Text';
|
import { KnobLabel, KnobValue } from '../primitives/Text';
|
||||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
import type { SkinProps } from './shared';
|
||||||
|
|
||||||
export interface FlatKnobProps extends SkinProps {
|
export interface FlatKnobProps extends SkinProps {
|
||||||
/** Accent color for the value arc and pointer. */
|
/** Accent color for the value arc and pointer. */
|
||||||
|
|
@ -14,7 +15,7 @@ export interface FlatKnobProps extends SkinProps {
|
||||||
pointerColor?: string;
|
pointerColor?: string;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
/** Draw the value arc from 'min' or from 'center' (pan-style). */
|
/** Draw the value arc from 'min' or from the anchor ('center', pan-style). */
|
||||||
arcFrom?: 'min' | 'center';
|
arcFrom?: 'min' | 'center';
|
||||||
arcThickness?: number;
|
arcThickness?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -22,45 +23,64 @@ 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.FC<FlatKnobProps> = ({
|
||||||
size = 80,
|
size = 80,
|
||||||
color = '#4cc2ff',
|
color,
|
||||||
trackColor = 'rgba(255,255,255,0.12)',
|
trackColor,
|
||||||
faceColor = 'rgba(255,255,255,0.05)',
|
faceColor,
|
||||||
pointerColor,
|
pointerColor,
|
||||||
textColor = 'rgba(255,255,255,0.92)',
|
textColor,
|
||||||
labelColor = 'rgba(255,255,255,0.45)',
|
labelColor,
|
||||||
arcFrom = 'min',
|
arcFrom = 'min',
|
||||||
arcThickness,
|
arcThickness,
|
||||||
label,
|
label,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
unit,
|
unit,
|
||||||
format,
|
format,
|
||||||
|
focusRing,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
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 size={size} className={className} style={style} {...core}>
|
<Knob
|
||||||
<Arc thickness={t} color={color} trackColor={trackColor} from={arcFrom} />
|
size={size}
|
||||||
<Face radius={size / 2 - t - 5} fill={faceColor} />
|
className={className}
|
||||||
|
style={style}
|
||||||
|
focusRing={focusRing ?? theme.focusRing}
|
||||||
|
{...core}
|
||||||
|
>
|
||||||
|
<Arc
|
||||||
|
thickness={t}
|
||||||
|
color={accent}
|
||||||
|
trackColor={trackColor ?? theme.track}
|
||||||
|
from={arcFrom}
|
||||||
|
/>
|
||||||
|
<Face radius={size / 2 - t - 5} fill={faceColor ?? theme.face} />
|
||||||
<Pointer
|
<Pointer
|
||||||
type="line"
|
type="line"
|
||||||
radius={size / 2 - t - 7}
|
radius={size / 2 - t - 7}
|
||||||
length={size * 0.16}
|
length={size * 0.16}
|
||||||
width={Math.max(2.5, size * 0.04)}
|
width={Math.max(2.5, size * 0.04)}
|
||||||
color={pointerColor ?? color}
|
color={pointerColor ?? accent}
|
||||||
/>
|
/>
|
||||||
{showValue && (
|
{showValue && (
|
||||||
<KnobValue
|
<KnobValue
|
||||||
color={textColor}
|
color={textColor ?? theme.text}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
format={format}
|
format={format}
|
||||||
fontSize={size * 0.17}
|
fontSize={size * 0.17}
|
||||||
fontFamily={MONO_FONT}
|
fontFamily={theme.fontMono}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.42} fontSize={size * 0.1}>
|
<KnobLabel
|
||||||
|
color={labelColor ?? theme.label}
|
||||||
|
fontFamily={theme.fontUI}
|
||||||
|
dy={size * 0.42}
|
||||||
|
fontSize={size * 0.1}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
164
packages/dreamknob/src/skins/ImageKnob.tsx
Normal file
164
packages/dreamknob/src/skins/ImageKnob.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useKnob } from '../hooks/useKnob';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { KnobCoreProps } from '../core/types';
|
||||||
|
import { ValueInput, defaultParseValue } from '../components/ValueInput';
|
||||||
|
|
||||||
|
export interface ImageKnobProps extends KnobCoreProps {
|
||||||
|
/** Film-strip image: every frame is one rotation step, stacked in a strip. */
|
||||||
|
src: string;
|
||||||
|
/** Number of frames in the strip. */
|
||||||
|
frames: number;
|
||||||
|
/** How the frames are stacked in the image. Default: 'vertical'. */
|
||||||
|
stripOrientation?: 'vertical' | 'horizontal';
|
||||||
|
/** Rendered frame size in px (frames are assumed square). Default: 90. */
|
||||||
|
size?: number;
|
||||||
|
label?: string;
|
||||||
|
showValue?: boolean;
|
||||||
|
unit?: string;
|
||||||
|
format?: (value: number) => string;
|
||||||
|
textColor?: string;
|
||||||
|
labelColor?: string;
|
||||||
|
/** Keyboard focus ring color, or false to disable. */
|
||||||
|
focusRing?: string | false;
|
||||||
|
/** Double-click to type an exact value. */
|
||||||
|
editable?: boolean;
|
||||||
|
parseValue?: (text: string) => number | null;
|
||||||
|
/** Extra styles for the image layer (e.g. filter, borderRadius). */
|
||||||
|
imageStyle?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Film-strip knob: renders one frame of a sprite strip per value position —
|
||||||
|
* the classic KnobMan / JUCE workflow for photoreal hardware knobs.
|
||||||
|
*/
|
||||||
|
export const ImageKnob: React.FC<ImageKnobProps> = ({
|
||||||
|
src,
|
||||||
|
frames,
|
||||||
|
stripOrientation = 'vertical',
|
||||||
|
size = 90,
|
||||||
|
label,
|
||||||
|
showValue = false,
|
||||||
|
unit,
|
||||||
|
format,
|
||||||
|
textColor,
|
||||||
|
labelColor,
|
||||||
|
focusRing,
|
||||||
|
editable = false,
|
||||||
|
parseValue,
|
||||||
|
imageStyle,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...core
|
||||||
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const knob = useKnob(core);
|
||||||
|
const [editing, setEditing] = React.useState(false);
|
||||||
|
const ring = focusRing ?? theme.focusRing;
|
||||||
|
|
||||||
|
const frame = frames > 1 ? Math.round(knob.normalized * (frames - 1)) : 0;
|
||||||
|
const p = frames > 1 ? (frame / (frames - 1)) * 100 : 0;
|
||||||
|
const vertical = stripOrientation === 'vertical';
|
||||||
|
|
||||||
|
const fixed = knob.value.toFixed(knob.decimals);
|
||||||
|
const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
|
||||||
|
const text = format ? format(knob.value) : `${trimmed}${unit ?? ''}`;
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
setEditing(false);
|
||||||
|
knob.ref.current?.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
width: size,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...knob.bind}
|
||||||
|
onDoubleClick={
|
||||||
|
editable
|
||||||
|
? e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
: knob.bind.onDoubleClick
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...knob.bind.style,
|
||||||
|
position: 'relative',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: `url(${src})`,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: vertical ? `100% ${frames * 100}%` : `${frames * 100}% 100%`,
|
||||||
|
backgroundPosition: vertical ? `0% ${p}%` : `${p}% 0%`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
...imageStyle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editing && (
|
||||||
|
<ValueInput
|
||||||
|
initial={String(knob.value)}
|
||||||
|
onCommit={raw => {
|
||||||
|
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||||
|
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
||||||
|
closeEditor();
|
||||||
|
}}
|
||||||
|
onCancel={closeEditor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showValue && (
|
||||||
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: textColor ?? theme.text,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{label && (
|
||||||
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: labelColor ?? theme.label,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useKnob } from '../hooks/useKnob';
|
import { useKnob } from '../hooks/useKnob';
|
||||||
import type { KnobCoreProps } from '../core/types';
|
import type { KnobCoreProps } from '../core/types';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
import { SegmentDisplay } from '../digital/SegmentDisplay';
|
import { SegmentDisplay } from '../digital/SegmentDisplay';
|
||||||
import { UI_FONT } from './shared';
|
import { ValueInput, defaultParseValue } from '../components/ValueInput';
|
||||||
|
|
||||||
export interface LEDFaderZone {
|
export interface LEDFaderZone {
|
||||||
/** Upper bound of the zone as a normalized position (0..1]. */
|
/** Upper bound of the zone as a normalized position (0..1]. */
|
||||||
|
|
@ -40,6 +41,10 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
/** Decimals in the readout (defaults to the control's decimals, capped at 1). */
|
/** Decimals in the readout (defaults to the control's decimals, capped at 1). */
|
||||||
displayDecimals?: number;
|
displayDecimals?: number;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
|
/** Click the readout to type an exact value. */
|
||||||
|
editable?: boolean;
|
||||||
|
/** Custom parser for typed input. */
|
||||||
|
parseValue?: (text: string) => number | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
@ -50,21 +55,27 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
length = 160,
|
length = 160,
|
||||||
breadth = 34,
|
breadth = 34,
|
||||||
segments = 24,
|
segments = 24,
|
||||||
color = '#3df2ad',
|
color: colorProp,
|
||||||
zones,
|
zones,
|
||||||
offOpacity = 0.13,
|
offOpacity = 0.13,
|
||||||
glow = true,
|
glow = true,
|
||||||
faceColor = '#0b0d0e',
|
faceColor = '#0b0d0e',
|
||||||
focusRing = 'rgba(130,180,255,0.65)',
|
focusRing,
|
||||||
label,
|
label,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
digits = 4,
|
digits = 4,
|
||||||
displayDecimals,
|
displayDecimals,
|
||||||
labelColor = 'rgba(255,255,255,0.45)',
|
labelColor,
|
||||||
|
editable = false,
|
||||||
|
parseValue,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
|
const [editing, setEditing] = React.useState(false);
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const color = colorProp ?? theme.accent ?? '#3df2ad';
|
||||||
|
const ring = focusRing ?? theme.focusRing;
|
||||||
const vertical = orientation === 'vertical';
|
const vertical = orientation === 'vertical';
|
||||||
const pad = 5;
|
const pad = 5;
|
||||||
const knob = useKnob({
|
const knob = useKnob({
|
||||||
|
|
@ -84,14 +95,17 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
const zoneFor = (t: number): string =>
|
const zoneFor = (t: number): string =>
|
||||||
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||||
|
|
||||||
const lit = Math.round(knob.normalized * segments);
|
// Bipolar controls light the segments between the origin and the value.
|
||||||
|
const originN = knob.originNormalized ?? 0;
|
||||||
|
const litFrom = Math.round(Math.min(knob.normalized, originN) * segments);
|
||||||
|
const litTo = Math.round(Math.max(knob.normalized, originN) * segments);
|
||||||
const slot = (length - pad * 2) / segments;
|
const slot = (length - pad * 2) / segments;
|
||||||
const gap = Math.min(3, slot * 0.35);
|
const gap = Math.min(3, slot * 0.35);
|
||||||
const cross = breadth - pad * 2;
|
const cross = breadth - pad * 2;
|
||||||
|
|
||||||
const leds: React.ReactNode[] = [];
|
const leds: React.ReactNode[] = [];
|
||||||
for (let i = 0; i < segments; i++) {
|
for (let i = 0; i < segments; i++) {
|
||||||
const on = i < lit;
|
const on = i >= litFrom && i < litTo;
|
||||||
const c = zoneFor((i + 1) / segments);
|
const c = zoneFor((i + 1) / segments);
|
||||||
const common = {
|
const common = {
|
||||||
rx: 1.5,
|
rx: 1.5,
|
||||||
|
|
@ -135,7 +149,32 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showValue && (
|
{showValue && (
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
pointerEvents: editable ? 'auto' : 'none',
|
||||||
|
cursor: editable ? 'text' : undefined,
|
||||||
|
minHeight: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
onClick={editable && !editing ? () => setEditing(true) : undefined}
|
||||||
|
title={editable ? 'Click to type a value' : undefined}
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<ValueInput
|
||||||
|
initial={String(knob.value)}
|
||||||
|
onCommit={raw => {
|
||||||
|
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||||
|
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditing(false)}
|
||||||
|
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<SegmentDisplay
|
<SegmentDisplay
|
||||||
value={knob.value}
|
value={knob.value}
|
||||||
digits={digits}
|
digits={digits}
|
||||||
|
|
@ -145,6 +184,7 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
background="none"
|
background="none"
|
||||||
ghostOpacity={0.07}
|
ghostOpacity={0.07}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|
@ -155,9 +195,7 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
height: h,
|
height: h,
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
...(knob.isFocusVisible && focusRing
|
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||||
? { boxShadow: `0 0 0 2px ${focusRing}` }
|
|
||||||
: undefined),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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">
|
||||||
|
|
@ -193,11 +231,11 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: UI_FONT,
|
fontFamily: theme.fontUI,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.08em',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: labelColor,
|
color: labelColor ?? theme.label,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { KnobLabel } from '../primitives/Text';
|
||||||
import { describeArc } from '../core/math';
|
import { describeArc } from '../core/math';
|
||||||
import { useKnobContext } from '../core/context';
|
import { useKnobContext } from '../core/context';
|
||||||
import { formatForDisplay, renderSegmentText } from '../digital/segments';
|
import { formatForDisplay, renderSegmentText } from '../digital/segments';
|
||||||
import { UI_FONT, type SkinProps } from './shared';
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { SkinProps } from './shared';
|
||||||
|
|
||||||
export interface LEDKnobProps extends SkinProps {
|
export interface LEDKnobProps extends SkinProps {
|
||||||
/** LED color. */
|
/** LED color. */
|
||||||
|
|
@ -83,22 +84,25 @@ 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.FC<LEDKnobProps> = ({
|
||||||
size = 90,
|
size = 90,
|
||||||
color = '#3df2ad',
|
color: colorProp,
|
||||||
offColor = 'rgba(255,255,255,0.07)',
|
offColor = 'rgba(255,255,255,0.07)',
|
||||||
segments = 24,
|
segments = 24,
|
||||||
digits = 3,
|
digits = 3,
|
||||||
displayDecimals,
|
displayDecimals,
|
||||||
labelColor = 'rgba(255,255,255,0.45)',
|
labelColor,
|
||||||
faceColor = '#0b0d0e',
|
faceColor = '#0b0d0e',
|
||||||
label,
|
label,
|
||||||
|
focusRing,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
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} {...core}>
|
<Knob size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||||
{ctx => (
|
{ctx => (
|
||||||
<>
|
<>
|
||||||
<defs>
|
<defs>
|
||||||
|
|
@ -120,7 +124,12 @@ export const LEDKnob: React.FC<LEDKnobProps> = ({
|
||||||
glowId={glowId}
|
glowId={glowId}
|
||||||
/>
|
/>
|
||||||
{label && (
|
{label && (
|
||||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.45} fontSize={size * 0.095}>
|
<KnobLabel
|
||||||
|
color={labelColor ?? theme.label}
|
||||||
|
fontFamily={theme.fontUI}
|
||||||
|
dy={size * 0.45}
|
||||||
|
fontSize={size * 0.095}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { Arc } from '../primitives/Arc';
|
||||||
import { Pointer } from '../primitives/Pointer';
|
import { Pointer } from '../primitives/Pointer';
|
||||||
import { Ticks } from '../primitives/Ticks';
|
import { Ticks } from '../primitives/Ticks';
|
||||||
import { KnobLabel, KnobValue } from '../primitives/Text';
|
import { KnobLabel, KnobValue } from '../primitives/Text';
|
||||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { SkinProps } from './shared';
|
||||||
|
|
||||||
export interface MetalKnobProps extends SkinProps {
|
export interface MetalKnobProps extends SkinProps {
|
||||||
/** Accent color for the active ticks / value arc. */
|
/** Accent color for the active ticks / value arc. */
|
||||||
|
|
@ -23,10 +24,10 @@ 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.FC<MetalKnobProps> = ({
|
||||||
size = 90,
|
size = 90,
|
||||||
color = '#4cc2ff',
|
color: colorProp,
|
||||||
tone = 'silver',
|
tone = 'silver',
|
||||||
tickColor = 'rgba(255,255,255,0.18)',
|
tickColor,
|
||||||
labelColor = 'rgba(255,255,255,0.45)',
|
labelColor,
|
||||||
indicatorColor,
|
indicatorColor,
|
||||||
textColor,
|
textColor,
|
||||||
showArc = true,
|
showArc = true,
|
||||||
|
|
@ -35,10 +36,13 @@ export const MetalKnob: React.FC<MetalKnobProps> = ({
|
||||||
showValue = false,
|
showValue = false,
|
||||||
unit,
|
unit,
|
||||||
format,
|
format,
|
||||||
|
focusRing,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const color = colorProp ?? theme.accent ?? '#4cc2ff';
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const rimId = `${id}-rim`;
|
const rimId = `${id}-rim`;
|
||||||
const faceId = `${id}-face`;
|
const faceId = `${id}-face`;
|
||||||
|
|
@ -57,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} {...core}>
|
<Knob 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]} />
|
||||||
|
|
@ -94,7 +98,7 @@ export const MetalKnob: React.FC<MetalKnobProps> = ({
|
||||||
radius={size / 2 - size * 0.055}
|
radius={size / 2 - size * 0.055}
|
||||||
length={size * 0.05}
|
length={size * 0.05}
|
||||||
width={Math.max(1, size * 0.015)}
|
width={Math.max(1, size * 0.015)}
|
||||||
color={tickColor}
|
color={tickColor ?? theme.ticks}
|
||||||
activeColor={color}
|
activeColor={color}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -124,12 +128,17 @@ export const MetalKnob: React.FC<MetalKnobProps> = ({
|
||||||
unit={unit}
|
unit={unit}
|
||||||
format={format}
|
format={format}
|
||||||
fontSize={size * 0.13}
|
fontSize={size * 0.13}
|
||||||
fontFamily={MONO_FONT}
|
fontFamily={theme.fontMono}
|
||||||
dy={size * 0.09}
|
dy={size * 0.09}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.44} fontSize={size * 0.1}>
|
<KnobLabel
|
||||||
|
color={labelColor ?? theme.label}
|
||||||
|
fontFamily={theme.fontUI}
|
||||||
|
dy={size * 0.44}
|
||||||
|
fontSize={size * 0.1}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { Knob } from '../components/Knob';
|
||||||
import { Arc } from '../primitives/Arc';
|
import { Arc } from '../primitives/Arc';
|
||||||
import { Pointer } from '../primitives/Pointer';
|
import { Pointer } from '../primitives/Pointer';
|
||||||
import { KnobLabel, KnobValue } from '../primitives/Text';
|
import { KnobLabel, KnobValue } from '../primitives/Text';
|
||||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { SkinProps } from './shared';
|
||||||
|
|
||||||
export interface NeonKnobProps extends SkinProps {
|
export interface NeonKnobProps extends SkinProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|
@ -16,23 +17,26 @@ 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.FC<NeonKnobProps> = ({
|
||||||
size = 84,
|
size = 84,
|
||||||
color = '#e44cff',
|
color: colorProp,
|
||||||
trackColor = 'rgba(255,255,255,0.06)',
|
trackColor = 'rgba(255,255,255,0.06)',
|
||||||
textColor,
|
textColor,
|
||||||
labelColor = 'rgba(255,255,255,0.4)',
|
labelColor,
|
||||||
arcFrom = 'min',
|
arcFrom = 'min',
|
||||||
label,
|
label,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
unit,
|
unit,
|
||||||
format,
|
format,
|
||||||
|
focusRing,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
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} {...core}>
|
<Knob size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||||
<defs>
|
<defs>
|
||||||
<filter
|
<filter
|
||||||
id={glowId}
|
id={glowId}
|
||||||
|
|
@ -72,12 +76,17 @@ export const NeonKnob: React.FC<NeonKnobProps> = ({
|
||||||
unit={unit}
|
unit={unit}
|
||||||
format={format}
|
format={format}
|
||||||
fontSize={size * 0.17}
|
fontSize={size * 0.17}
|
||||||
fontFamily={MONO_FONT}
|
fontFamily={theme.fontMono}
|
||||||
textProps={{ filter: `url(#${glowId})` }}
|
textProps={{ filter: `url(#${glowId})` }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.45} fontSize={size * 0.1}>
|
<KnobLabel
|
||||||
|
color={labelColor ?? theme.label}
|
||||||
|
fontFamily={theme.fontUI}
|
||||||
|
dy={size * 0.45}
|
||||||
|
fontSize={size * 0.1}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { Arc } from '../primitives/Arc';
|
||||||
import { GlowFilter } from '../primitives/GlowFilter';
|
import { GlowFilter } from '../primitives/GlowFilter';
|
||||||
import { Pointer } from '../primitives/Pointer';
|
import { Pointer } from '../primitives/Pointer';
|
||||||
import { KnobLabel, KnobValue } from '../primitives/Text';
|
import { KnobLabel, KnobValue } from '../primitives/Text';
|
||||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { SkinProps } from './shared';
|
||||||
|
|
||||||
export interface RubberKnobProps extends SkinProps {
|
export interface RubberKnobProps extends SkinProps {
|
||||||
/** Glow/accent color. */
|
/** Glow/accent color. */
|
||||||
|
|
@ -18,19 +19,22 @@ 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.FC<RubberKnobProps> = ({
|
||||||
size = 90,
|
size = 90,
|
||||||
color = '#ff9640',
|
color: colorProp,
|
||||||
trackColor = 'rgba(255,255,255,0.08)',
|
trackColor,
|
||||||
textColor = 'rgba(255,255,255,0.92)',
|
textColor,
|
||||||
labelColor = 'rgba(255,255,255,0.45)',
|
labelColor,
|
||||||
arcFrom = 'min',
|
arcFrom = 'min',
|
||||||
label,
|
label,
|
||||||
showValue = false,
|
showValue = false,
|
||||||
unit,
|
unit,
|
||||||
format,
|
format,
|
||||||
|
focusRing,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const color = colorProp ?? theme.accent ?? '#ff9640';
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const bodyId = `${id}-body`;
|
const bodyId = `${id}-body`;
|
||||||
const glowId = `${id}-glow`;
|
const glowId = `${id}-glow`;
|
||||||
|
|
@ -38,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} {...core}>
|
<Knob 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" />
|
||||||
|
|
@ -52,7 +56,7 @@ export const RubberKnob: React.FC<RubberKnobProps> = ({
|
||||||
radius={size / 2 - size * 0.05}
|
radius={size / 2 - size * 0.05}
|
||||||
thickness={Math.max(3, size * 0.045)}
|
thickness={Math.max(3, size * 0.045)}
|
||||||
color={color}
|
color={color}
|
||||||
trackColor={trackColor}
|
trackColor={trackColor ?? theme.track}
|
||||||
from={arcFrom}
|
from={arcFrom}
|
||||||
arcProps={{ filter: `url(#${glowId})` }}
|
arcProps={{ filter: `url(#${glowId})` }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -86,16 +90,21 @@ export const RubberKnob: React.FC<RubberKnobProps> = ({
|
||||||
/>
|
/>
|
||||||
{showValue && (
|
{showValue && (
|
||||||
<KnobValue
|
<KnobValue
|
||||||
color={textColor}
|
color={textColor ?? theme.text}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
format={format}
|
format={format}
|
||||||
fontSize={size * 0.15}
|
fontSize={size * 0.15}
|
||||||
fontFamily={MONO_FONT}
|
fontFamily={theme.fontMono}
|
||||||
dy={size * 0.02}
|
dy={size * 0.02}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.44} fontSize={size * 0.1}>
|
<KnobLabel
|
||||||
|
color={labelColor ?? theme.label}
|
||||||
|
fontFamily={theme.fontUI}
|
||||||
|
dy={size * 0.44}
|
||||||
|
fontSize={size * 0.1}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { Knob } from '../components/Knob';
|
||||||
import { Pointer } from '../primitives/Pointer';
|
import { Pointer } from '../primitives/Pointer';
|
||||||
import { Ticks } from '../primitives/Ticks';
|
import { Ticks } from '../primitives/Ticks';
|
||||||
import { KnobLabel } from '../primitives/Text';
|
import { KnobLabel } from '../primitives/Text';
|
||||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { SkinProps } from './shared';
|
||||||
|
|
||||||
export interface SteppedKnobProps extends SkinProps {
|
export interface SteppedKnobProps extends SkinProps {
|
||||||
/** Position names, e.g. ['LP','BP','HP']. Sets the number of detents. */
|
/** Position names, e.g. ['LP','BP','HP']. Sets the number of detents. */
|
||||||
|
|
@ -22,23 +23,35 @@ export const SteppedKnob: React.FC<SteppedKnobProps> = ({
|
||||||
size = 90,
|
size = 90,
|
||||||
positions,
|
positions,
|
||||||
steps,
|
steps,
|
||||||
color = '#ffd23e',
|
color: colorProp,
|
||||||
tickColor = 'rgba(255,255,255,0.18)',
|
tickColor,
|
||||||
faceColor = '#1c1d22',
|
faceColor = '#1c1d22',
|
||||||
textColor = 'rgba(255,255,255,0.92)',
|
textColor,
|
||||||
labelColor = 'rgba(255,255,255,0.45)',
|
labelColor,
|
||||||
label,
|
label,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
|
focusRing,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const color = colorProp ?? theme.accent ?? '#ffd23e';
|
||||||
const detents = positions ? positions.length : steps ?? 5;
|
const detents = positions ? positions.length : steps ?? 5;
|
||||||
const min = core.min ?? 0;
|
const min = core.min ?? 0;
|
||||||
const max = core.max ?? (positions ? positions.length - 1 : 100);
|
const max = core.max ?? (positions ? positions.length - 1 : 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Knob size={size} className={className} style={style} {...core} min={min} max={max} steps={detents}>
|
<Knob
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
focusRing={focusRing}
|
||||||
|
{...core}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
steps={detents}
|
||||||
|
>
|
||||||
{ctx => {
|
{ctx => {
|
||||||
const index = Math.round(ctx.normalized * (detents - 1));
|
const index = Math.round(ctx.normalized * (detents - 1));
|
||||||
return (
|
return (
|
||||||
|
|
@ -48,7 +61,7 @@ export const SteppedKnob: React.FC<SteppedKnobProps> = ({
|
||||||
radius={size / 2 - 1}
|
radius={size / 2 - 1}
|
||||||
length={size * 0.07}
|
length={size * 0.07}
|
||||||
width={Math.max(1.5, size * 0.022)}
|
width={Math.max(1.5, size * 0.022)}
|
||||||
color={tickColor}
|
color={tickColor ?? theme.ticks}
|
||||||
getTickProps={i => (i === index ? { stroke: color } : undefined)}
|
getTickProps={i => (i === index ? { stroke: color } : undefined)}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
|
|
@ -73,8 +86,8 @@ export const SteppedKnob: React.FC<SteppedKnobProps> = ({
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize={size * (positions ? 0.15 : 0.17)}
|
fontSize={size * (positions ? 0.15 : 0.17)}
|
||||||
fill={textColor}
|
fill={textColor ?? theme.text}
|
||||||
fontFamily={MONO_FONT}
|
fontFamily={theme.fontMono}
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
>
|
>
|
||||||
|
|
@ -82,7 +95,12 @@ export const SteppedKnob: React.FC<SteppedKnobProps> = ({
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.45} fontSize={size * 0.095}>
|
<KnobLabel
|
||||||
|
color={labelColor ?? theme.label}
|
||||||
|
fontFamily={theme.fontUI}
|
||||||
|
dy={size * 0.45}
|
||||||
|
fontSize={size * 0.095}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { TickLabels } from '../primitives/TickLabels';
|
||||||
import { KnobLabel } from '../primitives/Text';
|
import { KnobLabel } from '../primitives/Text';
|
||||||
import { polarToCartesian } from '../core/math';
|
import { polarToCartesian } from '../core/math';
|
||||||
import { useKnobContext } from '../core/context';
|
import { useKnobContext } from '../core/context';
|
||||||
import { UI_FONT, type SkinProps } from './shared';
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { SkinProps } from './shared';
|
||||||
|
|
||||||
/** VintageKnob has no numeric readout, so it doesn't accept readout props. */
|
/** VintageKnob has no numeric readout, so it doesn't accept readout props. */
|
||||||
export interface VintageKnobProps extends Omit<SkinProps, 'showValue' | 'unit' | 'format'> {
|
export interface VintageKnobProps extends Omit<SkinProps, 'showValue' | 'unit' | 'format'> {
|
||||||
|
|
@ -96,26 +97,30 @@ const ChickenHead: React.FC<{ bodyColor: string; indicatorColor: string }> = ({
|
||||||
export const VintageKnob: React.FC<VintageKnobProps> = ({
|
export const VintageKnob: React.FC<VintageKnobProps> = ({
|
||||||
size = 96,
|
size = 96,
|
||||||
bodyColor = '#efe6d0',
|
bodyColor = '#efe6d0',
|
||||||
scaleColor = 'rgba(255,255,255,0.35)',
|
scaleColor,
|
||||||
color,
|
color,
|
||||||
labelColor = 'rgba(255,255,255,0.45)',
|
labelColor,
|
||||||
scaleLabels,
|
scaleLabels,
|
||||||
indicatorColor,
|
indicatorColor,
|
||||||
label,
|
label,
|
||||||
|
focusRing,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
}) => (
|
}) => {
|
||||||
<Knob size={size} className={className} style={style} {...core}>
|
const theme = useKnobTheme();
|
||||||
|
const scale = scaleColor ?? theme.ticks;
|
||||||
|
return (
|
||||||
|
<Knob 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)}
|
||||||
length={size * 0.055}
|
length={size * 0.055}
|
||||||
width={Math.max(1.2, size * 0.016)}
|
width={Math.max(1.2, size * 0.016)}
|
||||||
color={scaleColor}
|
color={scale}
|
||||||
activeColor={color ?? scaleColor}
|
activeColor={color ?? scale}
|
||||||
/>
|
/>
|
||||||
{scaleLabels && <TickLabels labels={scaleLabels} color={scaleColor} fontFamily={UI_FONT} />}
|
{scaleLabels && <TickLabels labels={scaleLabels} color={scale} fontFamily={theme.fontUI} />}
|
||||||
<ChickenHead
|
<ChickenHead
|
||||||
bodyColor={bodyColor}
|
bodyColor={bodyColor}
|
||||||
indicatorColor={
|
indicatorColor={
|
||||||
|
|
@ -123,9 +128,15 @@ export const VintageKnob: React.FC<VintageKnobProps> = ({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{label && (
|
{label && (
|
||||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.46} fontSize={size * 0.095}>
|
<KnobLabel
|
||||||
|
color={labelColor ?? theme.label}
|
||||||
|
fontFamily={theme.fontUI}
|
||||||
|
dy={size * 0.46}
|
||||||
|
fontSize={size * 0.095}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
)}
|
)}
|
||||||
</Knob>
|
</Knob>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import type { KnobCoreProps } from '../core/types';
|
import type { KnobCoreProps } from '../core/types';
|
||||||
|
|
||||||
|
export { MONO_FONT, UI_FONT } from '../core/theme';
|
||||||
|
|
||||||
/** Props common to every prebuilt knob skin. */
|
/** Props common to every prebuilt knob skin. */
|
||||||
export interface SkinProps extends KnobCoreProps {
|
export interface SkinProps extends KnobCoreProps {
|
||||||
/** Square canvas size in px. */
|
/** Square canvas size in px. */
|
||||||
|
|
@ -13,11 +15,15 @@ export interface SkinProps extends KnobCoreProps {
|
||||||
unit?: string;
|
unit?: string;
|
||||||
/** Custom readout formatter (takes precedence over unit/decimals). */
|
/** Custom readout formatter (takes precedence over unit/decimals). */
|
||||||
format?: (value: number) => string;
|
format?: (value: number) => string;
|
||||||
|
/** Keyboard focus ring color, or false to disable. Defaults to the theme's. */
|
||||||
|
focusRing?: string | false;
|
||||||
|
/**
|
||||||
|
* Type-in editing: double-click (knobs) or click the readout (faders) to
|
||||||
|
* enter an exact value. Enter/blur commits, Escape cancels, "1.2k" works.
|
||||||
|
*/
|
||||||
|
editable?: boolean;
|
||||||
|
/** Custom parser for typed input when `editable` is set. */
|
||||||
|
parseValue?: (text: string) => number | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MONO_FONT =
|
|
||||||
"'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
|
||||||
export const UI_FONT =
|
|
||||||
"'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue