Tier 3: bubbles, animation, wrap mode, XYPad, AlphaDisplay, PushButton
- valueBubble/bubbleFormat: floating readout above knobs while dragging
(Knob-based skins + ImageKnob)
- animateChanges: rAF ease-out tween of the pointer on programmatic
value changes; user gestures snap; prefers-reduced-motion disables
- wrap: endless encoder mode — values and relative drags roll around
min↔max; pairs with angleOffset={0} angleRange={360}
- XYPad: two-parameter pad with absolute drag, arrow keys (Shift
coarse), Escape cancel, double-click reset, grid/crosshair, focus
ring, hidden form inputs, change meta
- AlphaDisplay: fourteen-segment alphanumeric LED (A-Z, 0-9, symbols,
decimal points, chars padding/align, glow/skew)
- PushButton: studio panel button with LED strip — toggle or momentary,
controlled/uncontrolled, aria-pressed, hidden form input
- Docs: five new gallery cards incl. animated preset demo, API rows,
README
This commit is contained in:
parent
0aee48b749
commit
e9e52e43ed
12 changed files with 936 additions and 8 deletions
|
|
@ -52,6 +52,9 @@ export const ApiDocs: React.FC = () => (
|
||||||
<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="origin" type="number" desc="Bipolar anchor: arcs and fader fills draw from this value instead of the minimum (origin={0} for pan/gain)." />
|
||||||
<Row name="detents / detentSize" type="number[] / number" def="— / 0.025" desc="Magnetic snap points: drags stick to these values within the capture radius. Wheel/keyboard unaffected." />
|
<Row name="detents / detentSize" type="number[] / number" def="— / 0.025" desc="Magnetic snap points: drags stick to these values within the capture radius. Wheel/keyboard unaffected." />
|
||||||
|
<Row name="wrap" type="boolean" def="false" desc="Endless encoder: values roll around min↔max. Pair with angleOffset={0} angleRange={360}." />
|
||||||
|
<Row name="animateChanges" type="boolean | {duration}" def="false" desc="Tween the pointer on programmatic changes (preset loads). Gestures never animate; respects prefers-reduced-motion." />
|
||||||
|
<Row name="valueBubble / bubbleFormat" type="boolean / fn" def="false" desc="Floating readout above the control while dragging." />
|
||||||
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
|
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
|
||||||
<Row name="rotaryMode" type="RotaryMode" def="'absolute'" desc="'absolute' tracks the pointer angle; 'relative' applies angle deltas (no grab jump); 'pickup' engages once the pointer sweeps past the value (MIDI pickup)." />
|
<Row name="rotaryMode" type="RotaryMode" def="'absolute'" desc="'absolute' tracks the pointer angle; 'relative' applies angle deltas (no grab jump); 'pickup' engages once the pointer sweeps past the value (MIDI pickup)." />
|
||||||
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
|
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
|
||||||
|
|
@ -95,6 +98,9 @@ export const ApiDocs: React.FC = () => (
|
||||||
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
|
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
|
||||||
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
|
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
|
||||||
<Row name="<Meter />" type="display" desc="Read-only LED level meter: value, zones, peakHold (ms), peakColor, orientation, seven-segment readout." />
|
<Row name="<Meter />" type="display" desc="Read-only LED level meter: value, zones, peakHold (ms), peakColor, orientation, seven-segment readout." />
|
||||||
|
<Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs, drag + arrow keys, grid, crosshair, Escape cancel, double-click reset." />
|
||||||
|
<Row name="<PushButton />" type="switch" desc="Panel button with LED strip: toggle or momentary, controlled/uncontrolled, aria-pressed." />
|
||||||
|
<Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: A–Z 0–9 and symbols, chars padding, align, glow, skew." />
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
|
AlphaDisplay,
|
||||||
Arc,
|
Arc,
|
||||||
DreamknobProvider,
|
DreamknobProvider,
|
||||||
Fader,
|
Fader,
|
||||||
|
|
@ -13,14 +14,57 @@ import {
|
||||||
Meter,
|
Meter,
|
||||||
NeonKnob,
|
NeonKnob,
|
||||||
Pointer,
|
Pointer,
|
||||||
|
PushButton,
|
||||||
RubberKnob,
|
RubberKnob,
|
||||||
SegmentDisplay,
|
SegmentDisplay,
|
||||||
SteppedKnob,
|
SteppedKnob,
|
||||||
Ticks,
|
Ticks,
|
||||||
VintageKnob,
|
VintageKnob,
|
||||||
|
XYPad,
|
||||||
logTaper,
|
logTaper,
|
||||||
} from 'dreamknob';
|
} from 'dreamknob';
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ name: 'INIT PATCH', cutoff: 50 },
|
||||||
|
{ name: 'WARM PAD', cutoff: 22 },
|
||||||
|
{ name: 'ACID 303', cutoff: 84 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PresetDemo: React.FC = () => {
|
||||||
|
const [preset, setPreset] = React.useState(0);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||||
|
<FlatKnob
|
||||||
|
size={84}
|
||||||
|
value={PRESETS[preset].cutoff}
|
||||||
|
onChange={() => undefined}
|
||||||
|
readOnly
|
||||||
|
animateChanges={{ duration: 350 }}
|
||||||
|
color="#3df2ad"
|
||||||
|
label="Cutoff"
|
||||||
|
aria-label="Preset cutoff"
|
||||||
|
/>
|
||||||
|
<AlphaDisplay value={PRESETS[preset].name} chars={10} height={16} color="#3df2ad" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{PRESETS.map((p, i) => (
|
||||||
|
<PushButton
|
||||||
|
key={p.name}
|
||||||
|
pressed={preset === i}
|
||||||
|
onChange={on => on && setPreset(i)}
|
||||||
|
color="#3df2ad"
|
||||||
|
size={34}
|
||||||
|
aria-label={`Preset ${p.name}`}
|
||||||
|
>
|
||||||
|
{String.fromCharCode(65 + i)}
|
||||||
|
</PushButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** Draw a film-strip of a simple hardware knob at runtime (stand-in for a KnobMan PNG). */
|
/** 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 useGeneratedStrip = (frames = 31, fs = 120): string | null => {
|
||||||
const [src, setSrc] = React.useState<string | null>(null);
|
const [src, setSrc] = React.useState<string | null>(null);
|
||||||
|
|
@ -340,6 +384,59 @@ export const Gallery: React.FC = () => (
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Animated presets + buttons" desc="animateChanges tweens programmatic changes; PushButton latches or is momentary." tag="animateChanges · <PushButton />">
|
||||||
|
<PresetDemo />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="XY pad" desc="Two parameters at once — cutoff/resonance, vector mixing, FX morphing." tag="<XYPad />">
|
||||||
|
<XYPad
|
||||||
|
width={190}
|
||||||
|
height={140}
|
||||||
|
x={{ min: 20, max: 20000, step: 1, defaultValue: 800 }}
|
||||||
|
y={{ min: 0, max: 100, step: 0.5, defaultValue: 30 }}
|
||||||
|
formatX={v => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
|
||||||
|
color="#e44cff"
|
||||||
|
label="Filter"
|
||||||
|
aria-label="Filter XY pad"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Endless encoder" desc="wrap makes the value roll around min↔max — full-turn phase and offset dials." tag="wrap · angleRange={360}">
|
||||||
|
<NeonKnob
|
||||||
|
size={92}
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
step={1}
|
||||||
|
defaultValue={90}
|
||||||
|
wrap
|
||||||
|
angleOffset={0}
|
||||||
|
angleRange={360}
|
||||||
|
unit="°"
|
||||||
|
color="#4cc2ff"
|
||||||
|
label="Phase"
|
||||||
|
aria-label="Phase encoder"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Drag bubble" desc="A floating readout follows the gesture — keep the knob face clean." tag="valueBubble">
|
||||||
|
<MetalKnob
|
||||||
|
size={92}
|
||||||
|
defaultValue={64}
|
||||||
|
step={1}
|
||||||
|
valueBubble
|
||||||
|
bubbleFormat={v => `${v} %`}
|
||||||
|
label="Blend"
|
||||||
|
aria-label="Bubble demo"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Alpha display" desc="Fourteen-segment alphanumeric LED — preset names, modes, messages." tag="<AlphaDisplay />">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center' }}>
|
||||||
|
<AlphaDisplay value="DREAMKNOB" height={20} />
|
||||||
|
<AlphaDisplay value="LP-24 SAT+2" height={16} color="#4cc2ff" />
|
||||||
|
</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" />
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@ All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
|
||||||
- `wheelStep` / `wheelRequiresFocus` — tune wheel notches, or keep hover-scroll free.
|
- `wheelStep` / `wheelRequiresFocus` — tune wheel notches, or keep hover-scroll free.
|
||||||
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks with
|
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks with
|
||||||
`meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'`).
|
`meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'`).
|
||||||
|
- `wrap` — endless encoder mode: values roll around min↔max (pair with
|
||||||
|
`angleOffset={0} angleRange={360}`).
|
||||||
|
- `animateChanges` — tween the pointer on programmatic changes (preset loads);
|
||||||
|
gestures never animate and `prefers-reduced-motion` is honored.
|
||||||
|
- `valueBubble` — floating readout above the control while dragging.
|
||||||
- `name` — hidden form input for plain `<form>` posts; every component forwards a
|
- `name` — hidden form input for plain `<form>` posts; every component forwards a
|
||||||
ref to its root element, and SVG parts carry `data-part` attributes for CSS.
|
ref to its root element, and SVG parts carry `data-part` attributes for CSS.
|
||||||
|
|
||||||
|
|
@ -89,7 +94,10 @@ import {
|
||||||
LEDFader, // segmented LED meter-fader with color zones
|
LEDFader, // segmented LED meter-fader with color zones
|
||||||
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
|
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
|
||||||
Meter, // read-only LED level meter with peak hold
|
Meter, // read-only LED level meter with peak hold
|
||||||
|
XYPad, // two-parameter pad (cutoff/resonance, vector mixing)
|
||||||
|
PushButton, // panel button with LED strip (toggle or momentary)
|
||||||
SegmentDisplay, // standalone seven-segment numeric display
|
SegmentDisplay, // standalone seven-segment numeric display
|
||||||
|
AlphaDisplay, // fourteen-segment alphanumeric display
|
||||||
} from 'dreamknob'
|
} from 'dreamknob'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ export interface KnobProps extends KnobCoreProps {
|
||||||
parseValue?: (text: string) => number | null;
|
parseValue?: (text: string) => number | null;
|
||||||
/** Render a hidden form input carrying the current value. */
|
/** Render a hidden form input carrying the current value. */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Show a floating value bubble above the knob while dragging. */
|
||||||
|
valueBubble?: boolean;
|
||||||
|
/** Formatter for the bubble text (defaults to the trimmed value). */
|
||||||
|
bubbleFormat?: (value: number) => string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
|
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
|
||||||
|
|
@ -42,6 +46,8 @@ export const Knob = React.forwardRef<HTMLDivElement, KnobProps>(function Knob(
|
||||||
editable = false,
|
editable = false,
|
||||||
parseValue,
|
parseValue,
|
||||||
name,
|
name,
|
||||||
|
valueBubble = false,
|
||||||
|
bubbleFormat,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
|
|
@ -128,7 +134,36 @@ export const Knob = React.forwardRef<HTMLDivElement, KnobProps>(function Knob(
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
|
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
|
||||||
|
{valueBubble && knob.isDragging && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 'calc(100% + 8px)',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: 'rgba(8, 9, 12, 0.92)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.16)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '2px 8px',
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.text,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(bubbleFormat ?? trimValue(knob.decimals))(knob.value)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</KnobContextProvider>
|
</KnobContextProvider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const trimValue =
|
||||||
|
(decimals: number) =>
|
||||||
|
(v: number): string => {
|
||||||
|
const s = v.toFixed(decimals);
|
||||||
|
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,19 @@ export interface KnobCoreProps {
|
||||||
detents?: readonly number[];
|
detents?: readonly number[];
|
||||||
/** Detent capture radius as a fraction of travel. Default: 0.025. */
|
/** Detent capture radius as a fraction of travel. Default: 0.025. */
|
||||||
detentSize?: number;
|
detentSize?: number;
|
||||||
|
/**
|
||||||
|
* Endless encoder: values wrap around min↔max instead of clamping (phase,
|
||||||
|
* LFO offset). Pair with `angleOffset={0} angleRange={360}` for a full-turn
|
||||||
|
* dial. Default: false.
|
||||||
|
*/
|
||||||
|
wrap?: boolean;
|
||||||
|
/**
|
||||||
|
* Tween the pointer when the value changes programmatically (preset loads,
|
||||||
|
* automation) instead of teleporting. `true` = 160 ms ease-out, or pass
|
||||||
|
* `{ duration }`. User gestures are never animated, and
|
||||||
|
* `prefers-reduced-motion` disables it. Default: false.
|
||||||
|
*/
|
||||||
|
animateChanges?: boolean | { duration?: number };
|
||||||
/** Drag behaviour. Default: 'rotary'. */
|
/** Drag behaviour. Default: 'rotary'. */
|
||||||
interaction?: InteractionMode;
|
interaction?: InteractionMode;
|
||||||
/** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */
|
/** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */
|
||||||
|
|
|
||||||
240
packages/dreamknob/src/digital/AlphaDisplay.tsx
Normal file
240
packages/dreamknob/src/digital/AlphaDisplay.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// Fourteen-segment geometry in a local 10 x 18 digit cell.
|
||||||
|
// Outer ring: a top, b top-right, c bottom-right, d bottom, e bottom-left,
|
||||||
|
// f top-left. Middle bar: g1 left, g2 right. Center verticals: i top, l
|
||||||
|
// bottom. Diagonals: h TL, j TR, k BL, m BR.
|
||||||
|
|
||||||
|
const W = 10;
|
||||||
|
const H = 18;
|
||||||
|
const GAP = 3.4;
|
||||||
|
const HALF = 1.05;
|
||||||
|
const INSET = 0.45;
|
||||||
|
|
||||||
|
const hSeg = (y: number, x1: number, x2: number): string => {
|
||||||
|
const a = x1 + INSET;
|
||||||
|
const b = x2 - INSET;
|
||||||
|
return `${a},${y} ${a + HALF},${y - HALF} ${b - HALF},${y - HALF} ${b},${y} ${b - HALF},${y + HALF} ${a + HALF},${y + HALF}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const vSeg = (x: number, y1: number, y2: number): string => {
|
||||||
|
const a = y1 + INSET;
|
||||||
|
const b = y2 - INSET;
|
||||||
|
return `${x},${a} ${x + HALF},${a + HALF} ${x + HALF},${b - HALF} ${x},${b} ${x - HALF},${b - HALF} ${x - HALF},${a + HALF}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Seg14 =
|
||||||
|
| 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
|
||||||
|
| 'g1' | 'g2' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm';
|
||||||
|
|
||||||
|
const POLY: Partial<Record<Seg14, string>> = {
|
||||||
|
a: hSeg(1, 1, 9),
|
||||||
|
d: hSeg(17, 1, 9),
|
||||||
|
g1: hSeg(9, 1, 5),
|
||||||
|
g2: hSeg(9, 5, 9),
|
||||||
|
f: vSeg(1, 1, 9),
|
||||||
|
b: vSeg(9, 1, 9),
|
||||||
|
e: vSeg(1, 9, 17),
|
||||||
|
c: vSeg(9, 9, 17),
|
||||||
|
i: vSeg(5, 1, 9),
|
||||||
|
l: vSeg(5, 9, 17),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Diagonals render as thick lines: [x1, y1, x2, y2]. */
|
||||||
|
const DIAG: Partial<Record<Seg14, [number, number, number, number]>> = {
|
||||||
|
h: [2.2, 2.7, 4.1, 7.2],
|
||||||
|
j: [7.8, 2.7, 5.9, 7.2],
|
||||||
|
k: [4.1, 10.8, 2.2, 15.3],
|
||||||
|
m: [5.9, 10.8, 7.8, 15.3],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHARS: Record<string, Seg14[]> = {
|
||||||
|
'0': ['a', 'b', 'c', 'd', 'e', 'f'],
|
||||||
|
'1': ['b', 'c'],
|
||||||
|
'2': ['a', 'b', 'g1', 'g2', 'e', 'd'],
|
||||||
|
'3': ['a', 'b', 'c', 'd', 'g1', 'g2'],
|
||||||
|
'4': ['f', 'g1', 'g2', 'b', 'c'],
|
||||||
|
'5': ['a', 'f', 'g1', 'g2', 'c', 'd'],
|
||||||
|
'6': ['a', 'f', 'g1', 'g2', 'e', 'c', 'd'],
|
||||||
|
'7': ['a', 'b', 'c'],
|
||||||
|
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g1', 'g2'],
|
||||||
|
'9': ['a', 'b', 'c', 'd', 'f', 'g1', 'g2'],
|
||||||
|
A: ['a', 'b', 'c', 'e', 'f', 'g1', 'g2'],
|
||||||
|
B: ['a', 'b', 'c', 'd', 'i', 'l', 'g2'],
|
||||||
|
C: ['a', 'd', 'e', 'f'],
|
||||||
|
D: ['a', 'b', 'c', 'd', 'i', 'l'],
|
||||||
|
E: ['a', 'd', 'e', 'f', 'g1', 'g2'],
|
||||||
|
F: ['a', 'e', 'f', 'g1', 'g2'],
|
||||||
|
G: ['a', 'c', 'd', 'e', 'f', 'g2'],
|
||||||
|
H: ['b', 'c', 'e', 'f', 'g1', 'g2'],
|
||||||
|
I: ['a', 'd', 'i', 'l'],
|
||||||
|
J: ['b', 'c', 'd', 'e'],
|
||||||
|
K: ['e', 'f', 'g1', 'j', 'm'],
|
||||||
|
L: ['d', 'e', 'f'],
|
||||||
|
M: ['b', 'c', 'e', 'f', 'h', 'j'],
|
||||||
|
N: ['b', 'c', 'e', 'f', 'h', 'm'],
|
||||||
|
O: ['a', 'b', 'c', 'd', 'e', 'f'],
|
||||||
|
P: ['a', 'b', 'e', 'f', 'g1', 'g2'],
|
||||||
|
Q: ['a', 'b', 'c', 'd', 'e', 'f', 'm'],
|
||||||
|
R: ['a', 'b', 'e', 'f', 'g1', 'g2', 'm'],
|
||||||
|
S: ['a', 'c', 'd', 'f', 'g1', 'g2'],
|
||||||
|
T: ['a', 'i', 'l'],
|
||||||
|
U: ['b', 'c', 'd', 'e', 'f'],
|
||||||
|
V: ['e', 'f', 'j', 'k'],
|
||||||
|
W: ['b', 'c', 'e', 'f', 'k', 'm'],
|
||||||
|
X: ['h', 'j', 'k', 'm'],
|
||||||
|
Y: ['h', 'j', 'l'],
|
||||||
|
Z: ['a', 'd', 'j', 'k'],
|
||||||
|
'-': ['g1', 'g2'],
|
||||||
|
'_': ['d'],
|
||||||
|
'=': ['g1', 'g2', 'd'],
|
||||||
|
'+': ['g1', 'g2', 'i', 'l'],
|
||||||
|
'*': ['g1', 'g2', 'h', 'i', 'j', 'k', 'l', 'm'],
|
||||||
|
'/': ['j', 'k'],
|
||||||
|
'\\': ['h', 'm'],
|
||||||
|
'?': ['a', 'b', 'g2', 'l'],
|
||||||
|
' ': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_SEGMENTS = Object.keys({ ...POLY, ...DIAG }) as Seg14[];
|
||||||
|
|
||||||
|
export interface AlphaDisplayProps {
|
||||||
|
/** Text: A–Z, 0–9, space, - _ = + * / \ ? and '.' (attaches to the previous cell). */
|
||||||
|
value: string;
|
||||||
|
/** Fixed number of character cells; the text is padded/cropped to fit. */
|
||||||
|
chars?: number;
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
/** Character height in px. Default: 22. */
|
||||||
|
height?: number;
|
||||||
|
color?: string;
|
||||||
|
/** Panel background. Set to 'none' to disable. */
|
||||||
|
background?: string;
|
||||||
|
/** Opacity of unlit segments. Default: 0.08. */
|
||||||
|
ghostOpacity?: number;
|
||||||
|
/** Italic skew in degrees. Default: 6. */
|
||||||
|
skew?: number;
|
||||||
|
/** LED glow strength (0 disables). Default: 1.4. */
|
||||||
|
glow?: number;
|
||||||
|
padding?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fourteen-segment alphanumeric LED display — preset names, modes, messages.
|
||||||
|
* The alphanumeric sibling of `SegmentDisplay`.
|
||||||
|
*/
|
||||||
|
export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
||||||
|
value,
|
||||||
|
chars,
|
||||||
|
align = 'left',
|
||||||
|
height = 22,
|
||||||
|
color = '#ffb84d',
|
||||||
|
background = '#0a0d0c',
|
||||||
|
ghostOpacity = 0.08,
|
||||||
|
skew = 6,
|
||||||
|
glow = 1.4,
|
||||||
|
padding = 6,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const filterId = React.useId();
|
||||||
|
|
||||||
|
let text = value.toUpperCase();
|
||||||
|
if (chars !== undefined) {
|
||||||
|
const cellCount = text.replace(/\./g, '').length;
|
||||||
|
if (cellCount < chars) {
|
||||||
|
const pad = ' '.repeat(chars - cellCount);
|
||||||
|
text = align === 'right' ? pad + text : text + pad;
|
||||||
|
} else if (cellCount > chars) {
|
||||||
|
let kept = 0;
|
||||||
|
let out = '';
|
||||||
|
for (const ch of text) {
|
||||||
|
if (ch !== '.' && kept === chars) break;
|
||||||
|
if (ch !== '.') kept++;
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
text = out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells: React.ReactNode[] = [];
|
||||||
|
let x = 0;
|
||||||
|
let key = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
if (ch === '.') {
|
||||||
|
cells.push(
|
||||||
|
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={H - 1.2} r={1.15} fill={color} />,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lit = new Set(CHARS[ch] ?? []);
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
for (const seg of ALL_SEGMENTS) {
|
||||||
|
const on = lit.has(seg);
|
||||||
|
if (!on && ghostOpacity <= 0) continue;
|
||||||
|
const opacity = on ? 1 : ghostOpacity;
|
||||||
|
const poly = POLY[seg];
|
||||||
|
if (poly) {
|
||||||
|
parts.push(<polygon key={seg} points={poly} fill={color} opacity={opacity} />);
|
||||||
|
} else {
|
||||||
|
const [x1, y1, x2, y2] = DIAG[seg]!;
|
||||||
|
parts.push(
|
||||||
|
<line
|
||||||
|
key={seg}
|
||||||
|
x1={x1}
|
||||||
|
y1={y1}
|
||||||
|
x2={x2}
|
||||||
|
y2={y2}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
opacity={opacity}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(
|
||||||
|
<g key={key++} transform={`translate(${x} 0)`}>
|
||||||
|
{parts}
|
||||||
|
</g>,
|
||||||
|
);
|
||||||
|
x += W + GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentW = Math.max(x - GAP, 0);
|
||||||
|
const scale = height / H;
|
||||||
|
const w = contentW * scale + padding * 2 + height * 0.15;
|
||||||
|
const h = height + padding * 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
viewBox={`0 0 ${w} ${h}`}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
role="img"
|
||||||
|
aria-label={value}
|
||||||
|
>
|
||||||
|
{glow > 0 && (
|
||||||
|
<defs>
|
||||||
|
<filter id={filterId} x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation={glow} result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
)}
|
||||||
|
{background !== 'none' && <rect x={0} y={0} width={w} height={h} rx={4} fill={background} />}
|
||||||
|
<g
|
||||||
|
transform={`translate(${padding + height * 0.12} ${padding}) scale(${scale})`}
|
||||||
|
filter={glow > 0 ? `url(#${filterId})` : undefined}
|
||||||
|
>
|
||||||
|
{skew ? <g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g> : cells}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -92,6 +92,8 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
origin,
|
origin,
|
||||||
detents,
|
detents,
|
||||||
detentSize = 0.025,
|
detentSize = 0.025,
|
||||||
|
wrap = false,
|
||||||
|
animateChanges = false,
|
||||||
fineMultiplier = 0.1,
|
fineMultiplier = 0.1,
|
||||||
angleOffset = 225,
|
angleOffset = 225,
|
||||||
angleRange = 270,
|
angleRange = 270,
|
||||||
|
|
@ -116,10 +118,16 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
props.doubleClickReset ?? props.defaultValue !== undefined;
|
props.doubleClickReset ?? props.defaultValue !== undefined;
|
||||||
const interactive = !disabled && !readOnly;
|
const interactive = !disabled && !readOnly;
|
||||||
|
|
||||||
/** Clamp, snap and round a raw value into an emittable one. */
|
/** Clamp (or wrap), snap and round a raw value into an emittable one. */
|
||||||
const constrain = React.useCallback(
|
const constrain = React.useCallback(
|
||||||
(raw: number): number => {
|
(raw: number): number => {
|
||||||
let v = clamp(raw, min, max);
|
let v: number;
|
||||||
|
if (wrap && max > min) {
|
||||||
|
const span = max - min;
|
||||||
|
v = min + ((((raw - min) % span) + span) % span);
|
||||||
|
} else {
|
||||||
|
v = clamp(raw, min, max);
|
||||||
|
}
|
||||||
if (values && values.length > 0) {
|
if (values && values.length > 0) {
|
||||||
v = findClosest(values, v);
|
v = findClosest(values, v);
|
||||||
} else if (steps && steps > 1) {
|
} else if (steps && steps > 1) {
|
||||||
|
|
@ -131,7 +139,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
}
|
}
|
||||||
return roundTo(v, decimals);
|
return roundTo(v, decimals);
|
||||||
},
|
},
|
||||||
[min, max, step, values, steps, taper, decimals],
|
[min, max, step, values, steps, taper, decimals, wrap],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isControlled = props.value !== undefined;
|
const isControlled = props.value !== undefined;
|
||||||
|
|
@ -167,10 +175,14 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
[constrain, isControlled, onChange],
|
[constrain, isControlled, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const wrapN = React.useCallback(
|
||||||
|
(n: number) => (wrap ? ((n % 1) + 1) % 1 : clamp(n, 0, 1)),
|
||||||
|
[wrap],
|
||||||
|
);
|
||||||
const setFromNormalized = React.useCallback(
|
const setFromNormalized = React.useCallback(
|
||||||
(n: number, source: ChangeSource = 'api') =>
|
(n: number, source: ChangeSource = 'api') =>
|
||||||
emit(taper.fromNormalized(clamp(n, 0, 1), min, max), source),
|
emit(taper.fromNormalized(wrapN(n), min, max), source),
|
||||||
[emit, taper, min, max],
|
[emit, taper, min, max, wrapN],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Detent positions in normalized space, applied only during pointer drags.
|
// Detent positions in normalized space, applied only during pointer drags.
|
||||||
|
|
@ -214,7 +226,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
if (delta > 180) delta -= 360;
|
if (delta > 180) delta -= 360;
|
||||||
if (delta < -180) delta += 360;
|
if (delta < -180) delta += 360;
|
||||||
const dn = (delta / angleRange) * (fine ? fineMultiplier : 1) * gain;
|
const dn = (delta / angleRange) * (fine ? fineMultiplier : 1) * gain;
|
||||||
s.n = clamp(s.n + dn, 0, 1);
|
s.n = wrapN(s.n + dn);
|
||||||
setFromDrag(s.n);
|
setFromDrag(s.n);
|
||||||
}
|
}
|
||||||
s.prevAngle = angle;
|
s.prevAngle = angle;
|
||||||
|
|
@ -257,7 +269,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
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 stays continuous so magnetic detents release cleanly.
|
||||||
s.n = clamp(s.n + dn, 0, 1);
|
s.n = wrapN(s.n + dn);
|
||||||
setFromDrag(s.n);
|
setFromDrag(s.n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,6 +287,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
trackInset,
|
trackInset,
|
||||||
fineMultiplier,
|
fineMultiplier,
|
||||||
setFromDrag,
|
setFromDrag,
|
||||||
|
wrapN,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -530,7 +543,49 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
onChangeEndRef.current?.(target, { source: 'reset' });
|
onChangeEndRef.current?.(target, { source: 'reset' });
|
||||||
}, [interactive, doubleClickReset, props.defaultValue, emit, constrain]);
|
}, [interactive, doubleClickReset, props.defaultValue, emit, constrain]);
|
||||||
|
|
||||||
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
|
// -------------------------------------------------------------------------
|
||||||
|
// Programmatic-change animation: the pointer glides, the readout/ARIA value
|
||||||
|
// stays exact. User gestures snap; prefers-reduced-motion disables it.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const animMs =
|
||||||
|
animateChanges === true
|
||||||
|
? 160
|
||||||
|
: typeof animateChanges === 'object'
|
||||||
|
? (animateChanges.duration ?? 160)
|
||||||
|
: 0;
|
||||||
|
const [displayValue, setDisplayValue] = React.useState(value);
|
||||||
|
const displayValueRef = React.useRef(displayValue);
|
||||||
|
displayValueRef.current = displayValue;
|
||||||
|
const rafRef = React.useRef<number>();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!animMs) return;
|
||||||
|
const reduced =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const gestureActive =
|
||||||
|
session.current !== null || wheelActive.current || keyAdjusted.current;
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
if (reduced || gestureActive || displayValueRef.current === value) {
|
||||||
|
setDisplayValue(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = displayValueRef.current;
|
||||||
|
const t0 = now();
|
||||||
|
const tick = (t: number) => {
|
||||||
|
const p = Math.min(1, (t - t0) / animMs);
|
||||||
|
const eased = 1 - Math.pow(1 - p, 3);
|
||||||
|
setDisplayValue(p >= 1 ? value : from + (value - from) * eased);
|
||||||
|
if (p < 1) rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [value, animMs]);
|
||||||
|
|
||||||
|
const shownValue = animMs ? displayValue : value;
|
||||||
|
const normalized = clamp(taper.toNormalized(shownValue, min, max), 0, 1);
|
||||||
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
||||||
const originNormalized =
|
const originNormalized =
|
||||||
origin === undefined
|
origin === undefined
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from '
|
||||||
|
|
||||||
// Digital
|
// Digital
|
||||||
export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay';
|
export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay';
|
||||||
|
export { AlphaDisplay, type AlphaDisplayProps } from './digital/AlphaDisplay';
|
||||||
|
|
||||||
// Value editing
|
// Value editing
|
||||||
export { defaultParseValue, ValueInput, type ValueInputProps } from './components/ValueInput';
|
export { defaultParseValue, ValueInput, type ValueInputProps } from './components/ValueInput';
|
||||||
|
|
@ -66,4 +67,6 @@ export { Fader, type FaderProps } from './skins/Fader';
|
||||||
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
|
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
|
||||||
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
|
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
|
||||||
export { Meter, type MeterProps } from './skins/Meter';
|
export { Meter, type MeterProps } from './skins/Meter';
|
||||||
|
export { PushButton, type PushButtonProps } from './skins/PushButton';
|
||||||
|
export { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad';
|
||||||
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ export interface ImageKnobProps extends KnobCoreProps {
|
||||||
/** Double-click to type an exact value. */
|
/** Double-click to type an exact value. */
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
parseValue?: (text: string) => number | null;
|
parseValue?: (text: string) => number | null;
|
||||||
|
/** Show a floating value bubble above the knob while dragging. */
|
||||||
|
valueBubble?: boolean;
|
||||||
|
bubbleFormat?: (value: number) => string;
|
||||||
/** Extra styles for the image layer (e.g. filter, borderRadius). */
|
/** Extra styles for the image layer (e.g. filter, borderRadius). */
|
||||||
imageStyle?: React.CSSProperties;
|
imageStyle?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -48,6 +51,8 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
||||||
focusRing,
|
focusRing,
|
||||||
editable = false,
|
editable = false,
|
||||||
parseValue,
|
parseValue,
|
||||||
|
valueBubble = false,
|
||||||
|
bubbleFormat,
|
||||||
imageStyle,
|
imageStyle,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
|
@ -131,6 +136,28 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
||||||
onCancel={closeEditor}
|
onCancel={closeEditor}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{valueBubble && knob.isDragging && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 'calc(100% + 8px)',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: 'rgba(8, 9, 12, 0.92)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.16)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '2px 8px',
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.text,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(bubbleFormat ?? (() => text))(knob.value)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showValue && (
|
{showValue && (
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
|
|
||||||
142
packages/dreamknob/src/skins/PushButton.tsx
Normal file
142
packages/dreamknob/src/skins/PushButton.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
|
||||||
|
export interface PushButtonProps {
|
||||||
|
/** Controlled pressed state. Pair with `onChange`. */
|
||||||
|
pressed?: boolean;
|
||||||
|
defaultPressed?: boolean;
|
||||||
|
onChange?: (pressed: boolean) => void;
|
||||||
|
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
|
||||||
|
mode?: 'toggle' | 'momentary';
|
||||||
|
/** LED / active color. */
|
||||||
|
color?: string;
|
||||||
|
/** Show the LED strip. Default: true. */
|
||||||
|
led?: boolean;
|
||||||
|
/** Button height in px. Default: 36. */
|
||||||
|
size?: number;
|
||||||
|
/** Explicit width; defaults to content. */
|
||||||
|
width?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Render a hidden form input carrying "on"/"off". */
|
||||||
|
name?: string;
|
||||||
|
/** Caption, e.g. "MUTE". */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Studio panel button with an LED strip — mute/solo/bypass in matching style. */
|
||||||
|
export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
|
function PushButton(
|
||||||
|
{
|
||||||
|
pressed,
|
||||||
|
defaultPressed = false,
|
||||||
|
onChange,
|
||||||
|
mode = 'toggle',
|
||||||
|
color,
|
||||||
|
led = true,
|
||||||
|
size = 36,
|
||||||
|
width,
|
||||||
|
disabled = false,
|
||||||
|
name,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...aria
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const accent = color ?? theme.accent ?? '#4cc2ff';
|
||||||
|
const isControlled = pressed !== undefined;
|
||||||
|
const [internal, setInternal] = React.useState(defaultPressed);
|
||||||
|
const on = isControlled ? (pressed as boolean) : internal;
|
||||||
|
|
||||||
|
const set = (next: boolean) => {
|
||||||
|
if (next === on) return;
|
||||||
|
if (!isControlled) setInternal(next);
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const momentaryProps =
|
||||||
|
mode === 'momentary'
|
||||||
|
? {
|
||||||
|
onPointerDown: (e: React.PointerEvent) => {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
set(true);
|
||||||
|
},
|
||||||
|
onPointerUp: () => set(false),
|
||||||
|
onPointerCancel: () => set(false),
|
||||||
|
onKeyDown: (e: React.KeyboardEvent) => {
|
||||||
|
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) set(true);
|
||||||
|
},
|
||||||
|
onKeyUp: (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') set(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { onClick: () => set(!on) };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={on}
|
||||||
|
aria-label={aria['aria-label']}
|
||||||
|
data-pressed={on ? '' : undefined}
|
||||||
|
className={className}
|
||||||
|
{...momentaryProps}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 3,
|
||||||
|
height: size,
|
||||||
|
width,
|
||||||
|
minWidth: size * 1.4,
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: 7,
|
||||||
|
border: '1px solid rgba(0,0,0,0.7)',
|
||||||
|
background: on
|
||||||
|
? 'linear-gradient(180deg, #17181d, #232429)'
|
||||||
|
: 'linear-gradient(180deg, #35363d, #1d1e24)',
|
||||||
|
boxShadow: on
|
||||||
|
? 'inset 0 2px 5px rgba(0,0,0,0.65)'
|
||||||
|
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)',
|
||||||
|
color: on ? theme.text : theme.label,
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: Math.max(10, size * 0.28),
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
transition: 'background 80ms, box-shadow 80ms, color 80ms',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{led && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: '55%',
|
||||||
|
height: Math.max(3, size * 0.09),
|
||||||
|
borderRadius: 3,
|
||||||
|
background: on ? accent : 'rgba(255,255,255,0.09)',
|
||||||
|
boxShadow: on ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
|
||||||
|
transition: 'background 80ms, box-shadow 80ms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
298
packages/dreamknob/src/skins/XYPad.tsx
Normal file
298
packages/dreamknob/src/skins/XYPad.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { ChangeMeta } from '../core/types';
|
||||||
|
|
||||||
|
export interface XYPadAxis {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
defaultValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XYPadValue {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XYPadProps {
|
||||||
|
/** X axis config (left → right = min → max). */
|
||||||
|
x?: XYPadAxis;
|
||||||
|
/** Y axis config (bottom → top = min → max, audio convention). */
|
||||||
|
y?: XYPadAxis;
|
||||||
|
/** Controlled position. Pair with `onChange`. */
|
||||||
|
value?: XYPadValue;
|
||||||
|
onChange?: (value: XYPadValue, meta: ChangeMeta) => void;
|
||||||
|
onChangeStart?: (value: XYPadValue, meta: ChangeMeta) => void;
|
||||||
|
onChangeEnd?: (value: XYPadValue, meta: ChangeMeta) => void;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
/** Handle / crosshair accent. */
|
||||||
|
color?: string;
|
||||||
|
faceColor?: string;
|
||||||
|
/** Grid divisions per axis (0 disables). Default: 4. */
|
||||||
|
grid?: number;
|
||||||
|
/** Show the "x · y" readout above the pad. Default: true. */
|
||||||
|
showValue?: boolean;
|
||||||
|
formatX?: (v: number) => string;
|
||||||
|
formatY?: (v: number) => string;
|
||||||
|
label?: string;
|
||||||
|
focusRing?: string | false;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Hidden form inputs for each axis. */
|
||||||
|
nameX?: string;
|
||||||
|
nameY?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trim = (v: number, decimals: number): string => {
|
||||||
|
const s = v.toFixed(decimals);
|
||||||
|
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Two-parameter XY pad — filter cutoff/resonance, vector mixing, FX morphing. */
|
||||||
|
export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad(
|
||||||
|
{
|
||||||
|
x: xAxis,
|
||||||
|
y: yAxis,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onChangeStart,
|
||||||
|
onChangeEnd,
|
||||||
|
width = 200,
|
||||||
|
height = 160,
|
||||||
|
color,
|
||||||
|
faceColor = 'rgba(255,255,255,0.04)',
|
||||||
|
grid = 4,
|
||||||
|
showValue = true,
|
||||||
|
formatX,
|
||||||
|
formatY,
|
||||||
|
label,
|
||||||
|
focusRing,
|
||||||
|
disabled = false,
|
||||||
|
nameX,
|
||||||
|
nameY,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...aria
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const accent = color ?? theme.accent ?? '#4cc2ff';
|
||||||
|
const ax = { min: 0, max: 100, step: 0, defaultValue: 50, ...xAxis };
|
||||||
|
const ay = { min: 0, max: 100, step: 0, defaultValue: 50, ...yAxis };
|
||||||
|
const dx = ax.step > 0 ? decimalsFromStep(ax.step) : 2;
|
||||||
|
const dy = ay.step > 0 ? decimalsFromStep(ay.step) : 2;
|
||||||
|
|
||||||
|
const conX = React.useCallback(
|
||||||
|
(v: number) =>
|
||||||
|
roundTo(clamp(ax.step > 0 ? snapToStep(v, ax.step, ax.min) : v, ax.min, ax.max), dx),
|
||||||
|
[ax.min, ax.max, ax.step, dx],
|
||||||
|
);
|
||||||
|
const conY = React.useCallback(
|
||||||
|
(v: number) =>
|
||||||
|
roundTo(clamp(ay.step > 0 ? snapToStep(v, ay.step, ay.min) : v, ay.min, ay.max), dy),
|
||||||
|
[ay.min, ay.max, ay.step, dy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const [internal, setInternal] = React.useState<XYPadValue>({
|
||||||
|
x: conX(ax.defaultValue),
|
||||||
|
y: conY(ay.defaultValue),
|
||||||
|
});
|
||||||
|
const pos: XYPadValue = {
|
||||||
|
x: conX(isControlled ? (value as XYPadValue).x : internal.x),
|
||||||
|
y: conY(isControlled ? (value as XYPadValue).y : internal.y),
|
||||||
|
};
|
||||||
|
const posRef = React.useRef(pos);
|
||||||
|
posRef.current = pos;
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const [isFocusVisible, setIsFocusVisible] = React.useState(false);
|
||||||
|
const pointerFocus = React.useRef(false);
|
||||||
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const session = React.useRef<{ pointerId: number; rect: DOMRect; start: XYPadValue } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = (next: XYPadValue) => {
|
||||||
|
const c = { x: conX(next.x), y: conY(next.y) };
|
||||||
|
if (c.x === posRef.current.x && c.y === posRef.current.y) return;
|
||||||
|
if (!isControlled) setInternal(c);
|
||||||
|
onChange?.(c, { source: 'drag' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPointer = (clientX: number, clientY: number) => {
|
||||||
|
const s = session.current;
|
||||||
|
if (!s || s.rect.width <= 0 || s.rect.height <= 0) return;
|
||||||
|
const nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1);
|
||||||
|
const ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1);
|
||||||
|
emit({ x: ax.min + nx * (ax.max - ax.min), y: ay.min + ny * (ay.max - ay.min) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const nx = ax.max === ax.min ? 0 : (pos.x - ax.min) / (ax.max - ax.min);
|
||||||
|
const ny = ay.max === ay.min ? 0 : (pos.y - ay.min) / (ay.max - ay.min);
|
||||||
|
const hx = nx * width;
|
||||||
|
const hy = (1 - ny) * height;
|
||||||
|
|
||||||
|
const gridLines: React.ReactNode[] = [];
|
||||||
|
for (let i = 1; grid > 1 && i < grid; i++) {
|
||||||
|
const gx = (i / grid) * width;
|
||||||
|
const gy = (i / grid) * height;
|
||||||
|
gridLines.push(
|
||||||
|
<line key={`v${i}`} x1={gx} y1={0} x2={gx} y2={height} stroke={theme.track} strokeWidth="1" />,
|
||||||
|
<line key={`h${i}`} x1={0} y1={gy} x2={width} y2={gy} stroke={theme.track} strokeWidth="1" />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ring = focusRing ?? theme.focusRing;
|
||||||
|
const text = `${formatX ? formatX(pos.x) : trim(pos.x, dx)} · ${formatY ? formatY(pos.y) : trim(pos.y, dy)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
width,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showValue && (
|
||||||
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.text,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
role="application"
|
||||||
|
aria-roledescription="X/Y pad"
|
||||||
|
aria-label={aria['aria-label']}
|
||||||
|
aria-valuetext={text}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
data-dragging={isDragging ? '' : undefined}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
|
ref={node => {
|
||||||
|
(rootRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
if (typeof ref === 'function') ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
}}
|
||||||
|
onPointerDown={e => {
|
||||||
|
if (disabled || session.current || !rootRef.current) return;
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
pointerFocus.current = true;
|
||||||
|
rootRef.current.focus({ preventScroll: true });
|
||||||
|
rootRef.current.setPointerCapture(e.pointerId);
|
||||||
|
session.current = {
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
rect: rootRef.current.getBoundingClientRect(),
|
||||||
|
start: posRef.current,
|
||||||
|
};
|
||||||
|
setIsDragging(true);
|
||||||
|
onChangeStart?.(posRef.current, { source: 'drag' });
|
||||||
|
applyPointer(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
onPointerMove={e => {
|
||||||
|
if (disabled || !session.current || e.pointerId !== session.current.pointerId) return;
|
||||||
|
applyPointer(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
onPointerUp={e => {
|
||||||
|
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||||
|
session.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
onChangeEnd?.(posRef.current, { source: 'drag' });
|
||||||
|
}}
|
||||||
|
onPointerCancel={e => {
|
||||||
|
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||||
|
session.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
onChangeEnd?.(posRef.current, { source: 'drag' });
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (e.key === 'Escape' && session.current) {
|
||||||
|
const start = session.current.start;
|
||||||
|
session.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
emit(start);
|
||||||
|
onChangeEnd?.(start, { source: 'drag' });
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stepX = (ax.step > 0 ? ax.step : (ax.max - ax.min) / 100) * (e.shiftKey ? 10 : 1);
|
||||||
|
const stepY = (ay.step > 0 ? ay.step : (ay.max - ay.min) / 100) * (e.shiftKey ? 10 : 1);
|
||||||
|
let handled = true;
|
||||||
|
const p = posRef.current;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft': emit({ ...p, x: p.x - stepX }); break;
|
||||||
|
case 'ArrowRight': emit({ ...p, x: p.x + stepX }); break;
|
||||||
|
case 'ArrowUp': emit({ ...p, y: p.y + stepY }); break;
|
||||||
|
case 'ArrowDown': emit({ ...p, y: p.y - stepY }); break;
|
||||||
|
default: handled = false;
|
||||||
|
}
|
||||||
|
if (handled) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
if (disabled) return;
|
||||||
|
emit({ x: ax.defaultValue, y: ay.defaultValue });
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocusVisible(!pointerFocus.current);
|
||||||
|
pointerFocus.current = false;
|
||||||
|
}}
|
||||||
|
onBlur={() => setIsFocusVisible(false)}
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
touchAction: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'crosshair',
|
||||||
|
opacity: disabled ? 0.45 : undefined,
|
||||||
|
outline: 'none',
|
||||||
|
borderRadius: 10,
|
||||||
|
...(isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }} aria-hidden="true">
|
||||||
|
<rect x={0.5} y={0.5} width={width - 1} height={height - 1} rx={9} fill={faceColor} stroke="rgba(255,255,255,0.1)" />
|
||||||
|
{gridLines}
|
||||||
|
<line x1={hx} y1={0} x2={hx} y2={height} stroke={accent} strokeWidth="1" opacity="0.35" />
|
||||||
|
<line x1={0} y1={hy} x2={width} y2={hy} stroke={accent} strokeWidth="1" opacity="0.35" />
|
||||||
|
<circle cx={hx} cy={hy} r={isDragging ? 9 : 7} fill={accent} opacity="0.25" />
|
||||||
|
<circle cx={hx} cy={hy} r={4.5} fill={accent} />
|
||||||
|
</svg>
|
||||||
|
{nameX && <input type="hidden" name={nameX} value={pos.x} readOnly />}
|
||||||
|
{nameY && <input type="hidden" name={nameY} value={pos.y} readOnly />}
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: theme.label,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -26,6 +26,10 @@ export interface SkinProps extends KnobCoreProps {
|
||||||
parseValue?: (text: string) => number | null;
|
parseValue?: (text: string) => number | null;
|
||||||
/** Render a hidden form input carrying the current value. */
|
/** Render a hidden form input carrying the current value. */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Show a floating value bubble above the knob while dragging. */
|
||||||
|
valueBubble?: boolean;
|
||||||
|
/** Formatter for the bubble text (defaults to the trimmed value). */
|
||||||
|
bubbleFormat?: (value: number) => string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue