From 0aee48b74974dc3475de2e8057468a2a2892cf0f Mon Sep 17 00:00:00 2001 From: Dreamodus Date: Sun, 12 Jul 2026 15:18:54 -0700 Subject: [PATCH] Tier 2: drag feel, wheel overhaul, refs/meta/forms, Meter, gradients - rotaryMode: 'absolute' | 'relative' (angle deltas, no grab jump) | 'pickup' (MIDI-style: engages when the pointer sweeps past the value) - dragAcceleration: velocity gain for relative drags (Shift bypasses); hideCursorOnDrag hides the cursor mid-gesture - Wheel: trackpad delta accumulation (flicks can't rocket the value, one notch per wheel click), wheelStep override, wheelRequiresFocus opt-out of the hover scroll-trap - onChange/onChangeStart/onChangeEnd now receive { source: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api' } - Every component forwards a ref to its root; name prop renders a hidden form input; SVG primitives carry data-part attributes - Arc gradient: position-anchored color stops rendered as sliced arcs (mixColors/sampleGradient exported, unit-tested) - New read-only Meter: LED level meter with peak hold, zones, readout - Fader capColor/capLength for custom handle styling - Docs: Meter/gradient/drag-feel gallery cards, white-cap fader demo, API rows, README --- apps/docs/src/sections/ApiDocs.tsx | 23 +- apps/docs/src/sections/Gallery.tsx | 73 +++++- packages/dreamknob/README.md | 11 +- packages/dreamknob/src/components/Knob.tsx | 38 +++- packages/dreamknob/src/core/colors.test.ts | 40 ++++ packages/dreamknob/src/core/colors.ts | 48 ++++ packages/dreamknob/src/core/types.ts | 43 +++- packages/dreamknob/src/hooks/useKnob.ts | 208 +++++++++++++----- packages/dreamknob/src/index.ts | 11 +- packages/dreamknob/src/primitives/Arc.tsx | 69 +++++- packages/dreamknob/src/primitives/Face.tsx | 4 +- packages/dreamknob/src/primitives/Pointer.tsx | 6 +- packages/dreamknob/src/primitives/Text.tsx | 2 + .../dreamknob/src/primitives/TickLabels.tsx | 2 +- packages/dreamknob/src/primitives/Ticks.tsx | 2 +- packages/dreamknob/src/skins/Fader.tsx | 51 ++++- packages/dreamknob/src/skins/FlatKnob.tsx | 7 +- packages/dreamknob/src/skins/ImageKnob.tsx | 11 +- packages/dreamknob/src/skins/LEDFader.tsx | 11 +- packages/dreamknob/src/skins/LEDKnob.tsx | 8 +- packages/dreamknob/src/skins/MetalKnob.tsx | 8 +- packages/dreamknob/src/skins/Meter.tsx | 181 +++++++++++++++ packages/dreamknob/src/skins/NeonKnob.tsx | 8 +- packages/dreamknob/src/skins/RubberKnob.tsx | 8 +- packages/dreamknob/src/skins/SteppedKnob.tsx | 7 +- packages/dreamknob/src/skins/VintageKnob.tsx | 8 +- packages/dreamknob/src/skins/shared.ts | 2 + 27 files changed, 756 insertions(+), 134 deletions(-) create mode 100644 packages/dreamknob/src/core/colors.test.ts create mode 100644 packages/dreamknob/src/core/colors.ts create mode 100644 packages/dreamknob/src/skins/Meter.tsx diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx index fe2fb8a..32d5509 100644 --- a/apps/docs/src/sections/ApiDocs.tsx +++ b/apps/docs/src/sections/ApiDocs.tsx @@ -53,13 +53,19 @@ export const ApiDocs: React.FC = () => ( + + + - + + + - - + + + @@ -88,12 +94,19 @@ export const ApiDocs: React.FC = () => ( +

All skins also take label, showValue, unit,{' '} - format, className, style plus every core prop - above. + format, className, style, name{' '} + plus every core prop above, and forward a ref to their root element. Inside the + SVG, parts carry data-part attributes (arc,{' '} + track, pointer, ticks, face,{' '} + value, label) and the root exposes{' '} + data-dragging/data-disabled/data-focus-visible{' '} + — style any state or part with plain CSS. <Arc> additionally + accepts gradient (position-anchored color stops).

Theming

diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx index 27b144b..68977af 100644 --- a/apps/docs/src/sections/Gallery.tsx +++ b/apps/docs/src/sections/Gallery.tsx @@ -10,6 +10,7 @@ import { LEDFader, LEDKnob, MetalKnob, + Meter, NeonKnob, Pointer, RubberKnob, @@ -57,6 +58,32 @@ const useGeneratedStrip = (frames = 31, fs = 120): string | null => { return src; }; +const MeterDemo: React.FC = () => { + const [levels, setLevels] = React.useState<[number, number]>([-18, -20]); + React.useEffect(() => { + let t = 0; + const id = setInterval(() => { + t += 0.09; + setLevels([ + -13 + Math.sin(t) * 6 + Math.random() * 7, + -14 + Math.sin(t * 1.3 + 1) * 6 + Math.random() * 7, + ]); + }, 90); + return () => clearInterval(id); + }, []); + const zones = [ + { upTo: 0.72, color: '#3df2ad' }, + { upTo: 0.9, color: '#ffd23e' }, + { upTo: 1, color: '#ff4d6b' }, + ]; + return ( +
+ + +
+ ); +}; + const ImageKnobDemo: React.FC = () => { const src = useGeneratedStrip(); if (!src) return null; @@ -136,8 +163,9 @@ export const Gallery: React.FC = () => ( - + + @@ -269,6 +297,49 @@ export const Gallery: React.FC = () => ( + + + + + + + + + + + + + + + + + diff --git a/packages/dreamknob/README.md b/packages/dreamknob/README.md index 06b7710..bc7c94b 100644 --- a/packages/dreamknob/README.md +++ b/packages/dreamknob/README.md @@ -64,7 +64,15 @@ All knobs render `role="slider"` with `aria-valuemin/max/now/text`. - `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. +- `rotaryMode` — `'absolute'` (rc-knob feel), `'relative'` (no grab jump), or + `'pickup'` (MIDI-style: engages when the pointer sweeps past the value). +- `dragAcceleration` — velocity gain for relative drags; `hideCursorOnDrag` hides + the cursor mid-gesture. +- `wheelStep` / `wheelRequiresFocus` — tune wheel notches, or keep hover-scroll free. +- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks with + `meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'`). +- `name` — hidden form input for plain `
` posts; every component forwards a + ref to its root element, and SVG parts carry `data-part` attributes for CSS. ## Prebuilt skins @@ -80,6 +88,7 @@ import { Fader, // linear channel fader, vertical or horizontal LEDFader, // segmented LED meter-fader with color zones ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips) + Meter, // read-only LED level meter with peak hold SegmentDisplay, // standalone seven-segment numeric display } from 'dreamknob' ``` diff --git a/packages/dreamknob/src/components/Knob.tsx b/packages/dreamknob/src/components/Knob.tsx index 3938d14..1ed87c8 100644 --- a/packages/dreamknob/src/components/Knob.tsx +++ b/packages/dreamknob/src/components/Knob.tsx @@ -20,6 +20,8 @@ export interface KnobProps extends KnobCoreProps { editable?: boolean; /** Custom parser for typed input, e.g. to accept "-6 dB" or "A4". */ parseValue?: (text: string) => number | null; + /** Render a hidden form input carrying the current value. */ + name?: string; className?: string; style?: React.CSSProperties; /** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */ @@ -33,17 +35,21 @@ export interface KnobProps extends KnobCoreProps { * (rotary/relative/track drag, wheel, keyboard, double-click reset, a11y) * and provides a render context for composable SVG primitives. */ -export const Knob: React.FC = ({ - size = 80, - focusRing, - editable = false, - parseValue, - className, - style, - children, - overlay, - ...core -}) => { +export const Knob = React.forwardRef(function Knob( + { + size = 80, + focusRing, + editable = false, + parseValue, + name, + className, + style, + children, + overlay, + ...core + }, + forwardedRef, +) { const theme = useKnobTheme(); const ring = focusRing ?? theme.focusRing; const knob = useKnob(core); @@ -76,10 +82,17 @@ export const Knob: React.FC = ({ setValue: knob.setValue, }; + const setRefs = (node: HTMLDivElement | null) => { + (knob.bind.ref as React.MutableRefObject).current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }; + return (
= ({ onCancel={closeEditor} /> )} + {name && }
); -}; +}); diff --git a/packages/dreamknob/src/core/colors.test.ts b/packages/dreamknob/src/core/colors.test.ts new file mode 100644 index 0000000..dc2a2fb --- /dev/null +++ b/packages/dreamknob/src/core/colors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { mixColors, sampleGradient } from './colors'; + +describe('mixColors', () => { + it('mixes hex colors', () => { + expect(mixColors('#000000', '#ffffff', 0.5)).toBe('#808080'); + expect(mixColors('#ff0000', '#00ff00', 0)).toBe('#ff0000'); + expect(mixColors('#ff0000', '#00ff00', 1)).toBe('#00ff00'); + }); + it('expands shorthand hex', () => { + expect(mixColors('#f00', '#f00', 0.5)).toBe('#ff0000'); + }); + it('falls back to the nearer endpoint for non-hex input', () => { + expect(mixColors('red', '#00ff00', 0.2)).toBe('red'); + expect(mixColors('red', '#00ff00', 0.8)).toBe('#00ff00'); + }); +}); + +describe('sampleGradient', () => { + const stops = [ + { at: 0, color: '#000000' }, + { at: 0.5, color: '#808080' }, + { at: 1, color: '#ffffff' }, + ]; + it('returns stop colors at their positions', () => { + expect(sampleGradient(stops, 0)).toBe('#000000'); + expect(sampleGradient(stops, 0.5)).toBe('#808080'); + expect(sampleGradient(stops, 1)).toBe('#ffffff'); + }); + it('interpolates between stops', () => { + expect(sampleGradient(stops, 0.25)).toBe('#404040'); + }); + it('clamps outside the stop range', () => { + expect(sampleGradient(stops, -1)).toBe('#000000'); + expect(sampleGradient(stops, 2)).toBe('#ffffff'); + }); + it('handles unsorted stops', () => { + expect(sampleGradient([stops[2], stops[0], stops[1]], 0.25)).toBe('#404040'); + }); +}); diff --git a/packages/dreamknob/src/core/colors.ts b/packages/dreamknob/src/core/colors.ts new file mode 100644 index 0000000..c5ee54b --- /dev/null +++ b/packages/dreamknob/src/core/colors.ts @@ -0,0 +1,48 @@ +/** A color stop along the normalized travel (at ∈ [0, 1]). */ +export interface GradientStop { + at: number; + color: string; +} + +const parseHex = (color: string): [number, number, number, number] | null => { + const m = color.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i); + if (!m) return null; + let h = m[1]; + if (h.length <= 4) h = [...h].map(c => c + c).join(''); + const n = parseInt(h.length === 6 ? `${h}ff` : h, 16); + return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]; +}; + +/** Mix two hex colors. Non-hex inputs fall back to the nearer endpoint. */ +export const mixColors = (a: string, b: string, t: number): string => { + const ca = parseHex(a); + const cb = parseHex(b); + if (!ca || !cb) return t < 0.5 ? a : b; + const lerp = (x: number, y: number) => Math.round(x + (y - x) * t); + const [r, g, bl, al] = [0, 1, 2, 3].map(i => lerp(ca[i], cb[i])); + const hex = (v: number) => v.toString(16).padStart(2, '0'); + return al === 255 + ? `#${hex(r)}${hex(g)}${hex(bl)}` + : `#${hex(r)}${hex(g)}${hex(bl)}${hex(al)}`; +}; + +/** + * Sample a gradient at normalized position `t`. Stops are sorted by `at`; + * positions outside the stops clamp to the nearest end. + */ +export const sampleGradient = (stops: readonly GradientStop[], t: number): string => { + if (stops.length === 0) return '#000'; + const sorted = [...stops].sort((a, b) => a.at - b.at); + if (t <= sorted[0].at) return sorted[0].color; + const last = sorted[sorted.length - 1]; + if (t >= last.at) return last.color; + for (let i = 1; i < sorted.length; i++) { + if (t <= sorted[i].at) { + const a = sorted[i - 1]; + const b = sorted[i]; + const span = b.at - a.at; + return mixColors(a.color, b.color, span > 0 ? (t - a.at) / span : 0); + } + } + return last.color; +}; diff --git a/packages/dreamknob/src/core/types.ts b/packages/dreamknob/src/core/types.ts index 0f8cc3a..54912d6 100644 --- a/packages/dreamknob/src/core/types.ts +++ b/packages/dreamknob/src/core/types.ts @@ -1,5 +1,21 @@ import type { Taper } from './math'; +/** What produced a value change. */ +export type ChangeSource = 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'; + +export interface ChangeMeta { + source: ChangeSource; +} + +/** How the grab-and-rotate pointer angle maps to the value in rotary mode. */ +export type RotaryMode = + /** Value tracks the pointer's absolute angle (rc-knob feel). Default. */ + | 'absolute' + /** Angle *deltas* adjust the value — no jump when grabbing off-position. */ + | 'relative' + /** Ignore the pointer until it sweeps past the current position, then track it (MIDI pickup). */ + | 'pickup'; + /** How pointer dragging maps to value changes. */ export type InteractionMode = /** Track the pointer's absolute angle around the knob center (grab-and-rotate, rc-knob feel). */ @@ -53,8 +69,18 @@ export interface KnobCoreProps { detentSize?: number; /** Drag behaviour. Default: 'rotary'. */ interaction?: InteractionMode; + /** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */ + rotaryMode?: RotaryMode; /** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */ dragSensitivity?: number; + /** + * Velocity-based gain for relative drags (vertical/horizontal/both and + * relative rotary): fast drags cover more range, slow drags stay precise. + * 0 disables (default); 1–3 are sensible. Fine mode (Shift) bypasses it. + */ + dragAcceleration?: number; + /** Hide the mouse cursor while dragging (FabFilter style). Default: false. */ + hideCursorOnDrag?: boolean; /** * Dead margin (px) at each end of the element in `track-*` modes, so pointer * mapping lines up with a handle that can't travel the full rect (half the @@ -69,16 +95,23 @@ export interface KnobCoreProps { angleRange?: number; /** Enable mouse-wheel adjustment. Default: true. */ enableWheel?: boolean; + /** Value change per wheel notch. Defaults to `step` (or 1% of the range). */ + wheelStep?: number; + /** + * Only respond to the wheel while the control is focused, letting the page + * scroll normally otherwise. Default: false (hovering captures the wheel). + */ + wheelRequiresFocus?: boolean; /** Reset to `defaultValue` on double click. Default: true when `defaultValue` is set. */ doubleClickReset?: boolean; disabled?: boolean; readOnly?: boolean; - /** Fired with each (rounded, snapped) value change. */ - onChange?: (value: number) => void; + /** Fired with each (rounded, snapped) value change, with what caused it. */ + onChange?: (value: number, meta: ChangeMeta) => void; /** Fired when an adjustment gesture ends (pointer up, wheel settle, key release). */ - onChangeEnd?: (value: number) => void; - /** Fired when a drag gesture starts. */ - onChangeStart?: (value: number) => void; + onChangeEnd?: (value: number, meta: ChangeMeta) => void; + /** Fired when an adjustment gesture starts. */ + onChangeStart?: (value: number, meta: ChangeMeta) => void; /** Accessible name for the slider role. */ 'aria-label'?: string; 'aria-labelledby'?: string; diff --git a/packages/dreamknob/src/hooks/useKnob.ts b/packages/dreamknob/src/hooks/useKnob.ts index 69804d4..45e202e 100644 --- a/packages/dreamknob/src/hooks/useKnob.ts +++ b/packages/dreamknob/src/hooks/useKnob.ts @@ -11,7 +11,7 @@ import { roundTo, snapToStep, } from '../core/math'; -import type { KnobCoreProps, KnobState } from '../core/types'; +import type { ChangeSource, KnobCoreProps, KnobState } from '../core/types'; export interface UseKnobResult extends KnobState { /** True when focus came from the keyboard — render a focus ring. */ @@ -59,12 +59,22 @@ interface DragSession { rect: DOMRect; lastX: number; lastY: number; + lastT: number; /** Continuous normalized position, kept un-snapped for smooth relative drags. */ n: number; /** Value at gesture start, restored when Escape cancels the drag. */ startValue: number; + /** Previous pointer angle (relative rotary mode). */ + prevAngle?: number; + /** Previous pointer position in normalized space (pickup mode). */ + prevPointerN?: number; + /** Pickup mode: whether the pointer has reached the value yet. */ + engaged: boolean; } +const now = (): number => + typeof performance !== 'undefined' ? performance.now() : 0; + export function useKnob(props: KnobCoreProps): UseKnobResult { const { min: rawMin = 0, @@ -74,7 +84,10 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { steps, taper = linearTaper, interaction = 'rotary', + rotaryMode = 'absolute', dragSensitivity = 200, + dragAcceleration = 0, + hideCursorOnDrag = false, trackInset = 0, origin, detents, @@ -83,6 +96,8 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { angleOffset = 225, angleRange = 270, enableWheel = true, + wheelStep, + wheelRequiresFocus = false, disabled = false, readOnly = false, onChange, @@ -137,7 +152,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { onChangeStartRef.current = onChangeStart; const emit = React.useCallback( - (raw: number) => { + (raw: number, source: ChangeSource = 'api') => { const next = constrain(raw); if (next === valueRef.current) return; // Only pre-commit in uncontrolled mode. In controlled mode the parent @@ -147,13 +162,14 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { valueRef.current = next; setInternalValue(next); } - onChange?.(next); + onChange?.(next, { source }); }, [constrain, isControlled, onChange], ); const setFromNormalized = React.useCallback( - (n: number) => emit(taper.fromNormalized(clamp(n, 0, 1), min, max)), + (n: number, source: ChangeSource = 'api') => + emit(taper.fromNormalized(clamp(n, 0, 1), min, max), source), [emit, taper, min, max], ); @@ -165,7 +181,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { ); const setFromDrag = React.useCallback( (n: number) => - setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n), + setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n, 'drag'), [setFromNormalized, detentsN, detentSize], ); @@ -179,26 +195,62 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { (clientX: number, clientY: number, fine: boolean) => { const s = session.current; if (!s) return; + const t = now(); + const dt = Math.max(1, t - s.lastT); + const dist = Math.hypot(clientX - s.lastX, clientY - s.lastY); + // Velocity gain for relative modes; capped so a flick can't overshoot + // the whole range in one event. + const gain = + fine || dragAcceleration <= 0 + ? 1 + : 1 + dragAcceleration * Math.min(dist / dt, 4); + switch (interaction) { case 'rotary': { const angle = angleFromPoint(clientX, clientY, s.centerX, s.centerY); - setFromDrag(normalizedFromAngle(angle, angleOffset % 360, angleRange)); + if (rotaryMode === 'relative') { + if (s.prevAngle !== undefined) { + let delta = angle - s.prevAngle; + if (delta > 180) delta -= 360; + if (delta < -180) delta += 360; + const dn = (delta / angleRange) * (fine ? fineMultiplier : 1) * gain; + s.n = clamp(s.n + dn, 0, 1); + setFromDrag(s.n); + } + s.prevAngle = angle; + } else { + const n = normalizedFromAngle(angle, angleOffset % 360, angleRange); + if (!s.engaged) { + // Pickup: engage when the pointer reaches or sweeps across the + // current position, so grabbing off-position never jumps. + const crossed = + s.prevPointerN !== undefined && + (s.prevPointerN - s.n) * (n - s.n) <= 0; + if (Math.abs(n - s.n) <= 0.04 || crossed) s.engaged = true; + s.prevPointerN = n; + if (!s.engaged) break; + } + s.n = n; + setFromDrag(n); + } break; } case 'track-vertical': { const travel = s.rect.height - trackInset * 2; if (travel <= 0) break; - setFromDrag((s.rect.bottom - trackInset - clientY) / travel); + s.n = clamp((s.rect.bottom - trackInset - clientY) / travel, 0, 1); + setFromDrag(s.n); break; } case 'track-horizontal': { const travel = s.rect.width - trackInset * 2; if (travel <= 0) break; - setFromDrag((clientX - s.rect.left - trackInset) / travel); + s.n = clamp((clientX - s.rect.left - trackInset) / travel, 0, 1); + setFromDrag(s.n); break; } default: { - const scale = (fine ? fineMultiplier : 1) / dragSensitivity; + const scale = ((fine ? fineMultiplier : 1) * gain) / dragSensitivity; let dn = 0; if (interaction === 'vertical' || interaction === 'both') dn += (s.lastY - clientY) * scale; @@ -211,8 +263,19 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { } s.lastX = clientX; s.lastY = clientY; + s.lastT = t; }, - [interaction, angleOffset, angleRange, dragSensitivity, trackInset, fineMultiplier, setFromDrag], + [ + interaction, + rotaryMode, + angleOffset, + angleRange, + dragSensitivity, + dragAcceleration, + trackInset, + fineMultiplier, + setFromDrag, + ], ); const onPointerDown = React.useCallback( @@ -234,17 +297,19 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { centerY: rect.top + rect.height / 2, lastX: e.clientX, lastY: e.clientY, + lastT: now(), n: clamp(taper.toNormalized(valueRef.current, min, max), 0, 1), startValue: valueRef.current, + engaged: !(interaction === 'rotary' && rotaryMode === 'pickup'), }; setIsDragging(true); - onChangeStartRef.current?.(valueRef.current); + onChangeStartRef.current?.(valueRef.current, { source: 'drag' }); // Fader-style modes jump to the pressed position immediately. if (interaction === 'track-vertical' || interaction === 'track-horizontal') { applyPointer(e.clientX, e.clientY, e.shiftKey); } }, - [interactive, interaction, taper, min, max, applyPointer], + [interactive, interaction, rotaryMode, taper, min, max, applyPointer], ); const onPointerMove = React.useCallback( @@ -256,29 +321,45 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { [interactive, applyPointer], ); + const endDrag = React.useCallback((e: React.PointerEvent) => { + if (!session.current || e.pointerId !== session.current.pointerId) return; + session.current = null; + setIsDragging(false); + onChangeEndRef.current?.(valueRef.current, { source: 'drag' }); + }, []); + // If the control becomes disabled/readOnly mid-gesture, cancel the drag — // otherwise it would keep emitting while rendered as disabled. React.useEffect(() => { if (interactive || !session.current) return; session.current = null; setIsDragging(false); - onChangeEndRef.current?.(valueRef.current); + onChangeEndRef.current?.(valueRef.current, { source: 'drag' }); }, [interactive]); - const endDrag = React.useCallback((e: React.PointerEvent) => { - if (!session.current || e.pointerId !== session.current.pointerId) return; - session.current = null; - setIsDragging(false); - onChangeEndRef.current?.(valueRef.current); - }, []); + // Optionally hide the mouse cursor for the duration of a drag. + React.useEffect(() => { + if (!hideCursorOnDrag || !isDragging || typeof document === 'undefined') return; + const prev = document.body.style.cursor; + document.body.style.cursor = 'none'; + return () => { + document.body.style.cursor = prev; + }; + }, [hideCursorOnDrag, isDragging]); // ------------------------------------------------------------------------- // Wheel — needs a non-passive listener so preventDefault stops page scroll. // ------------------------------------------------------------------------- const wheelSettle = React.useRef>(); const wheelActive = React.useRef(false); + const wheelAccum = React.useRef(0); const nudge = React.useCallback( - (direction: number, multiplier = 1) => { + ( + direction: number, + multiplier = 1, + source: ChangeSource = 'api', + stepOverride?: number, + ) => { const current = valueRef.current; // Discrete controls move whole detents: fine mode still moves one, // coarse mode jumps several. @@ -286,16 +367,18 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { if (values && values.length > 0) { const sorted = [...values].sort((a, b) => a - b); const idx = sorted.indexOf(findClosest(sorted, current)); - emit(sorted[clamp(idx + direction * detentCount, 0, sorted.length - 1)]); + emit(sorted[clamp(idx + direction * detentCount, 0, sorted.length - 1)], source); } else if (steps && steps > 1) { const n = taper.toNormalized(current, min, max); - setFromNormalized(n + (direction * detentCount) / (steps - 1)); - } else if (step > 0) { + setFromNormalized(n + (direction * detentCount) / (steps - 1), source); + } else if (stepOverride !== undefined || step > 0) { + const base = stepOverride ?? step; // A fractional multiplier would snap straight back to the current - // step — never emit less than one step. - emit(current + direction * step * Math.max(1, multiplier)); + // step — never emit less than one step when snapping is on. + const effective = step > 0 ? Math.max(1, multiplier) : multiplier; + emit(current + direction * base * effective, source); } else { - emit(current + direction * ((max - min) / 100) * multiplier); + emit(current + direction * ((max - min) / 100) * multiplier, source); } }, [values, steps, step, min, max, taper, emit, setFromNormalized], @@ -307,24 +390,47 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { const el = ref.current; if (!el || !enableWheel || !interactive) return; const onWheel = (e: WheelEvent) => { + // Opt-out of the hover scroll-trap: let the page scroll unless focused. + if (wheelRequiresFocus && document.activeElement !== el) return; e.preventDefault(); // A wheel tick mid-drag would be undone by the next pointermove // (the drag session tracks its own position) — ignore it. if (session.current) return; - if (!wheelActive.current) { - wheelActive.current = true; - onChangeStartRef.current?.(valueRef.current); - } // Dominant axis, scroll-up / scroll-right increases. Using deltaY with // deltaX only as fallback keeps macOS Shift+scroll (which remaps Y to X) // from reversing direction. - const d = e.deltaY !== 0 ? -e.deltaY : -e.deltaX; - const direction = d > 0 ? 1 : -1; - nudgeRef.current(direction, e.shiftKey ? fineMultiplier : 1); + const raw = e.deltaY !== 0 ? -e.deltaY : -e.deltaX; + // Normalize notches: a discrete wheel click (|delta| ~100) is one notch; + // small trackpad deltas accumulate so a flick can't rocket the value. + let notches: number; + if (e.deltaMode !== 0) { + notches = Math.sign(raw) * Math.max(1, Math.round(Math.abs(raw) / 3)); + wheelAccum.current = 0; + } else if (Math.abs(raw) >= 50) { + notches = Math.sign(raw) * Math.max(1, Math.round(Math.abs(raw) / 100)); + wheelAccum.current = 0; + } else { + if (Math.sign(raw) !== Math.sign(wheelAccum.current)) wheelAccum.current = 0; + wheelAccum.current += raw; + notches = Math.trunc(wheelAccum.current / 50); + wheelAccum.current -= notches * 50; + } + if (!notches) return; + if (!wheelActive.current) { + wheelActive.current = true; + onChangeStartRef.current?.(valueRef.current, { source: 'wheel' }); + } + nudgeRef.current( + Math.sign(notches), + Math.abs(notches) * (e.shiftKey ? fineMultiplier : 1), + 'wheel', + wheelStep, + ); clearTimeout(wheelSettle.current); wheelSettle.current = setTimeout(() => { wheelActive.current = false; - onChangeEndRef.current?.(valueRef.current); + wheelAccum.current = 0; + onChangeEndRef.current?.(valueRef.current, { source: 'wheel' }); }, 250); }; el.addEventListener('wheel', onWheel, { passive: false }); @@ -335,10 +441,10 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { // gesture's onChangeEnd. if (wheelActive.current) { wheelActive.current = false; - onChangeEndRef.current?.(valueRef.current); + onChangeEndRef.current?.(valueRef.current, { source: 'wheel' }); } }; - }, [enableWheel, interactive, fineMultiplier]); + }, [enableWheel, interactive, fineMultiplier, wheelStep, wheelRequiresFocus]); // ------------------------------------------------------------------------- // Keyboard @@ -354,8 +460,8 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { e.preventDefault(); session.current = null; setIsDragging(false); - emit(s.startValue); - onChangeEndRef.current?.(s.startValue); + emit(s.startValue, 'drag'); + onChangeEndRef.current?.(s.startValue, { source: 'drag' }); return; } const span = max - min; @@ -363,23 +469,23 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { switch (e.key) { case 'ArrowUp': case 'ArrowRight': - nudge(1, e.shiftKey ? 10 : 1); + nudge(1, e.shiftKey ? 10 : 1, 'keyboard'); break; case 'ArrowDown': case 'ArrowLeft': - nudge(-1, e.shiftKey ? 10 : 1); + nudge(-1, e.shiftKey ? 10 : 1, 'keyboard'); break; case 'PageUp': - emit(valueRef.current + span / 10); + emit(valueRef.current + span / 10, 'keyboard'); break; case 'PageDown': - emit(valueRef.current - span / 10); + emit(valueRef.current - span / 10, 'keyboard'); break; case 'Home': - emit(min); + emit(min, 'keyboard'); break; case 'End': - emit(max); + emit(max, 'keyboard'); break; default: handled = false; @@ -395,7 +501,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { const onKeyUp = React.useCallback(() => { if (keyAdjusted.current) { keyAdjusted.current = false; - onChangeEndRef.current?.(valueRef.current); + onChangeEndRef.current?.(valueRef.current, { source: 'keyboard' }); } }, []); @@ -412,16 +518,16 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { // Flush a pending gesture end if the user tabs away while holding a key. if (keyAdjusted.current) { keyAdjusted.current = false; - onChangeEndRef.current?.(valueRef.current); + onChangeEndRef.current?.(valueRef.current, { source: 'keyboard' }); } }, []); const onDoubleClick = React.useCallback(() => { if (!interactive || !doubleClickReset || props.defaultValue === undefined) return; const target = constrain(props.defaultValue); - onChangeStartRef.current?.(valueRef.current); - emit(target); - onChangeEndRef.current?.(target); + onChangeStartRef.current?.(valueRef.current, { source: 'reset' }); + emit(target, 'reset'); + onChangeEndRef.current?.(target, { source: 'reset' }); }, [interactive, doubleClickReset, props.defaultValue, emit, constrain]); const normalized = clamp(taper.toNormalized(value, min, max), 0, 1); @@ -485,7 +591,9 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { : readOnly ? 'default' : isDragging - ? 'grabbing' + ? hideCursorOnDrag + ? 'none' + : 'grabbing' : 'grab', opacity: disabled ? 0.45 : undefined, outline: 'none', diff --git a/packages/dreamknob/src/index.ts b/packages/dreamknob/src/index.ts index 66b4d5c..60f9127 100644 --- a/packages/dreamknob/src/index.ts +++ b/packages/dreamknob/src/index.ts @@ -12,7 +12,15 @@ export { type KnobTheme, type DreamknobProviderProps, } from './core/theme'; -export type { KnobCoreProps, KnobState, InteractionMode } from './core/types'; +export type { + KnobCoreProps, + KnobState, + InteractionMode, + RotaryMode, + ChangeMeta, + ChangeSource, +} from './core/types'; +export { mixColors, sampleGradient, type GradientStop } from './core/colors'; export { clamp, roundTo, @@ -57,4 +65,5 @@ export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob'; export { Fader, type FaderProps } from './skins/Fader'; export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader'; export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob'; +export { Meter, type MeterProps } from './skins/Meter'; export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter'; diff --git a/packages/dreamknob/src/primitives/Arc.tsx b/packages/dreamknob/src/primitives/Arc.tsx index bdaa659..ce5b4e2 100644 --- a/packages/dreamknob/src/primitives/Arc.tsx +++ b/packages/dreamknob/src/primitives/Arc.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { useKnobContext } from '../core/context'; import { describeArc } from '../core/math'; +import { sampleGradient, type GradientStop } from '../core/colors'; export interface ArcProps { /** Arc radius. Defaults to (size - thickness) / 2. */ @@ -8,6 +9,13 @@ export interface ArcProps { thickness?: number; /** Value arc color. */ color?: string; + /** + * Position-anchored gradient for the value arc, e.g. green→yellow→red: + * `[{at: 0, color: '#3df2ad'}, {at: 0.8, color: '#ffd23e'}, {at: 1, color: '#ff4d6b'}]`. + * Colors stay fixed in space (the red zone is always at the top of travel). + * Overrides `color`. + */ + gradient?: readonly GradientStop[]; /** Background track color. Omit to skip the track. */ trackColor?: string; /** @@ -27,6 +35,7 @@ export const Arc: React.FC = ({ radius, thickness = 4, color = 'currentColor', + gradient, trackColor, from = 'min', cap = 'round', @@ -49,19 +58,39 @@ export const Arc: React.FC = ({ end = angleOffset + normalized * angleRange; } - return ( - - {trackColor && ( - - )} - {end - start > 0.0001 && ( + let valueArc: React.ReactNode = null; + if (end - start > 0.0001) { + if (gradient && gradient.length > 0) { + // SVG can't run a gradient along a path, so approximate with short + // butt-capped slices sampled from the gradient at their absolute + // position across the travel. + const sweep = end - start; + const slices = Math.max(2, Math.ceil(sweep / 4)); + const parts: React.ReactNode[] = []; + for (let i = 0; i < slices; i++) { + const a0 = start + (i / slices) * sweep; + const a1 = start + ((i + 1) / slices) * sweep + (i < slices - 1 ? 0.35 : 0); + const mid = (a0 + a1) / 2; + parts.push( + , + ); + } + valueArc = ( + )}> + {parts} + + ); + } else { + valueArc = ( = ({ fill="none" {...arcProps} /> + ); + } + } + + return ( + + {trackColor && ( + )} + {valueArc} ); }; diff --git a/packages/dreamknob/src/primitives/Face.tsx b/packages/dreamknob/src/primitives/Face.tsx index c236d92..1a06a98 100644 --- a/packages/dreamknob/src/primitives/Face.tsx +++ b/packages/dreamknob/src/primitives/Face.tsx @@ -9,5 +9,7 @@ export interface FaceProps extends React.SVGProps { /** The knob body: a centered circle. Style via fill/stroke/filter props. */ export const Face: React.FC = ({ radius, ...rest }) => { const { size, center } = useKnobContext(); - return ; + return ( + + ); }; diff --git a/packages/dreamknob/src/primitives/Pointer.tsx b/packages/dreamknob/src/primitives/Pointer.tsx index 36ee574..7abd7a5 100644 --- a/packages/dreamknob/src/primitives/Pointer.tsx +++ b/packages/dreamknob/src/primitives/Pointer.tsx @@ -69,5 +69,9 @@ export const Pointer: React.FC = ({ } } - return {shape}; + return ( + + {shape} + + ); }; diff --git a/packages/dreamknob/src/primitives/Text.tsx b/packages/dreamknob/src/primitives/Text.tsx index cf1aa63..ee01cf4 100644 --- a/packages/dreamknob/src/primitives/Text.tsx +++ b/packages/dreamknob/src/primitives/Text.tsx @@ -42,6 +42,7 @@ export const KnobValue: React.FC = ({ }${unit ?? ''}`; return ( = ({ const { size, center } = useKnobContext(); return ( = ({ const { size, center: c, angleOffset, angleRange } = useKnobContext(); const r = radius ?? size / 2 - size * 0.02; return ( - + {labels.map((text, i) => { if (!text) return null; const t = labels.length === 1 ? 0 : i / (labels.length - 1); diff --git a/packages/dreamknob/src/primitives/Ticks.tsx b/packages/dreamknob/src/primitives/Ticks.tsx index ceee406..4472269 100644 --- a/packages/dreamknob/src/primitives/Ticks.tsx +++ b/packages/dreamknob/src/primitives/Ticks.tsx @@ -51,5 +51,5 @@ export const Ticks: React.FC = ({ />, ); } - return {ticks}; + return {ticks}; }; diff --git a/packages/dreamknob/src/skins/Fader.tsx b/packages/dreamknob/src/skins/Fader.tsx index 657fccb..11670ae 100644 --- a/packages/dreamknob/src/skins/Fader.tsx +++ b/packages/dreamknob/src/skins/Fader.tsx @@ -14,6 +14,10 @@ export interface FaderProps extends Omit { breadth?: number; color?: string; trackColor?: string; + /** Base color of the cap handle (e.g. '#e8e8ec' for an SSL-style white cap). */ + capColor?: string; + /** Cap size along the travel axis in px. Default: 20. */ + capLength?: number; /** Fill the travelled part of the track. Default: true. */ showFill?: boolean; tickCount?: number; @@ -33,13 +37,15 @@ export interface FaderProps extends Omit { } /** Studio channel fader: absolute-position linear control with a cap handle. */ -export const Fader: React.FC = ({ +export const Fader = React.forwardRef(function Fader({ orientation = 'vertical', focusRing, length = 160, breadth = 44, color: colorProp, trackColor, + capColor, + capLength, showFill = true, tickCount = 9, tickColor, @@ -54,7 +60,7 @@ export const Fader: React.FC = ({ className, style, ...core -}) => { +}, ref) { const [editing, setEditing] = React.useState(false); const theme = useKnobTheme(); const color = colorProp ?? theme.accent ?? '#4cc2ff'; @@ -62,7 +68,7 @@ export const Fader: React.FC = ({ const resolvedTrack = trackColor ?? theme.track; const resolvedTicks = tickColor ?? theme.ticks; const vertical = orientation === 'vertical'; - const capMain = 20; // handle size along the travel axis + const capMain = capLength ?? 20; // handle size along the travel axis const knob = useKnob({ ...core, interaction: vertical ? 'track-vertical' : 'track-horizontal', @@ -167,6 +173,11 @@ export const Fader: React.FC = ({ )}
{ + (knob.bind.ref as React.MutableRefObject).current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }} style={{ ...knob.bind.style, width: w, @@ -178,13 +189,25 @@ export const Fader: React.FC = ({ >
); -}; +}); diff --git a/packages/dreamknob/src/skins/FlatKnob.tsx b/packages/dreamknob/src/skins/FlatKnob.tsx index 24f2f3f..49802b6 100644 --- a/packages/dreamknob/src/skins/FlatKnob.tsx +++ b/packages/dreamknob/src/skins/FlatKnob.tsx @@ -21,7 +21,7 @@ export interface FlatKnobProps extends SkinProps { } /** Clean 2D knob — the modern DAW/plugin look. */ -export const FlatKnob: React.FC = ({ +export const FlatKnob = React.forwardRef(function FlatKnob({ size = 80, color, trackColor, @@ -39,12 +39,13 @@ export const FlatKnob: React.FC = ({ className, style, ...core -}) => { +}, ref) { const theme = useKnobTheme(); const accent = color ?? theme.accent ?? '#4cc2ff'; const t = arcThickness ?? Math.max(3, size * 0.055); return ( = ({ )} ); -}; +}); diff --git a/packages/dreamknob/src/skins/ImageKnob.tsx b/packages/dreamknob/src/skins/ImageKnob.tsx index eecfddc..cf2fa51 100644 --- a/packages/dreamknob/src/skins/ImageKnob.tsx +++ b/packages/dreamknob/src/skins/ImageKnob.tsx @@ -34,7 +34,7 @@ export interface ImageKnobProps extends KnobCoreProps { * 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 = ({ +export const ImageKnob = React.forwardRef(function ImageKnob({ src, frames, stripOrientation = 'vertical', @@ -52,7 +52,7 @@ export const ImageKnob: React.FC = ({ className, style, ...core -}) => { +}, ref) { const theme = useKnobTheme(); const knob = useKnob(core); const [editing, setEditing] = React.useState(false); @@ -85,6 +85,11 @@ export const ImageKnob: React.FC = ({ >
{ + (knob.bind.ref as React.MutableRefObject).current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }} onDoubleClick={ editable ? e => { @@ -161,4 +166,4 @@ export const ImageKnob: React.FC = ({ )}
); -}; +}); diff --git a/packages/dreamknob/src/skins/LEDFader.tsx b/packages/dreamknob/src/skins/LEDFader.tsx index 9f74c90..9d7add1 100644 --- a/packages/dreamknob/src/skins/LEDFader.tsx +++ b/packages/dreamknob/src/skins/LEDFader.tsx @@ -50,7 +50,7 @@ export interface LEDFaderProps extends Omit { } /** Segmented LED fader — an interactive meter-style linear control. */ -export const LEDFader: React.FC = ({ +export const LEDFader = React.forwardRef(function LEDFader({ orientation = 'vertical', length = 160, breadth = 34, @@ -71,7 +71,7 @@ export const LEDFader: React.FC = ({ className, style, ...core -}) => { +}, ref) { const [editing, setEditing] = React.useState(false); const theme = useKnobTheme(); const color = colorProp ?? theme.accent ?? '#3df2ad'; @@ -189,6 +189,11 @@ export const LEDFader: React.FC = ({ )}
{ + (knob.bind.ref as React.MutableRefObject).current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }} style={{ ...knob.bind.style, width: w, @@ -246,4 +251,4 @@ export const LEDFader: React.FC = ({ )}
); -}; +}); diff --git a/packages/dreamknob/src/skins/LEDKnob.tsx b/packages/dreamknob/src/skins/LEDKnob.tsx index f5df007..850d7c1 100644 --- a/packages/dreamknob/src/skins/LEDKnob.tsx +++ b/packages/dreamknob/src/skins/LEDKnob.tsx @@ -82,7 +82,7 @@ const CenterDisplay: React.FC<{ }; /** Digital knob: segmented LED ring around a real seven-segment readout. */ -export const LEDKnob: React.FC = ({ +export const LEDKnob = React.forwardRef(function LEDKnob({ size = 90, color: colorProp, offColor = 'rgba(255,255,255,0.07)', @@ -96,13 +96,13 @@ export const LEDKnob: React.FC = ({ className, style, ...core -}) => { +}, ref) { const theme = useKnobTheme(); const color = colorProp ?? theme.accent ?? '#3df2ad'; const id = React.useId(); const glowId = `${id}-glow`; return ( - + {ctx => ( <> @@ -137,4 +137,4 @@ export const LEDKnob: React.FC = ({ )} ); -}; +}); diff --git a/packages/dreamknob/src/skins/MetalKnob.tsx b/packages/dreamknob/src/skins/MetalKnob.tsx index 2bed5fa..46ff198 100644 --- a/packages/dreamknob/src/skins/MetalKnob.tsx +++ b/packages/dreamknob/src/skins/MetalKnob.tsx @@ -22,7 +22,7 @@ export interface MetalKnobProps extends SkinProps { } /** 3D brushed-aluminum knob with a knurled rim — classic hi-fi hardware. */ -export const MetalKnob: React.FC = ({ +export const MetalKnob = React.forwardRef(function MetalKnob({ size = 90, color: colorProp, tone = 'silver', @@ -40,7 +40,7 @@ export const MetalKnob: React.FC = ({ className, style, ...core -}) => { +}, ref) { const theme = useKnobTheme(); const color = colorProp ?? theme.accent ?? '#4cc2ff'; const id = React.useId(); @@ -61,7 +61,7 @@ export const MetalKnob: React.FC = ({ const indicator = indicatorColor ?? (silver ? '#1b1c21' : color); return ( - + @@ -144,4 +144,4 @@ export const MetalKnob: React.FC = ({ )} ); -}; +}); diff --git a/packages/dreamknob/src/skins/Meter.tsx b/packages/dreamknob/src/skins/Meter.tsx new file mode 100644 index 0000000..3a5aaa1 --- /dev/null +++ b/packages/dreamknob/src/skins/Meter.tsx @@ -0,0 +1,181 @@ +import * as React from 'react'; +import { clamp, linearTaper } from '../core/math'; +import type { Taper } from '../core/math'; +import { useKnobTheme } from '../core/theme'; +import type { LEDFaderZone } from './LEDFader'; +import { SegmentDisplay } from '../digital/SegmentDisplay'; + +export interface MeterProps { + /** Level to display. The meter is read-only — always controlled. */ + value: number; + min?: number; + max?: number; + taper?: Taper; + orientation?: 'vertical' | 'horizontal'; + /** Travel length in px. Default: 160. */ + length?: number; + /** Cross-axis size in px. Default: 16. */ + breadth?: number; + /** Number of LED segments. Default: 28. */ + segments?: number; + /** LED color when no zones are given. */ + color?: string; + /** Meter zones, e.g. green / yellow / red by normalized position. */ + zones?: readonly LEDFaderZone[]; + /** Opacity of unlit segments. Default: 0.1. */ + offOpacity?: number; + /** + * Peak hold time in ms — the highest recent segment stays lit and falls + * back after this long. `false` disables. Default: 1200. + */ + peakHold?: number | false; + /** Peak indicator color. Defaults to the peak's zone color. */ + peakColor?: string; + /** Panel color behind the LEDs. */ + faceColor?: string; + label?: string; + /** Show a seven-segment readout. Default: false. */ + showValue?: boolean; + digits?: number; + displayDecimals?: number; + labelColor?: string; + className?: string; + style?: React.CSSProperties; + 'aria-label'?: string; +} + +/** + * Read-only LED level meter with peak hold — the display-side sibling of + * LEDFader for VU/output metering. + */ +export const Meter: React.FC = ({ + value, + min = 0, + max = 100, + taper = linearTaper, + orientation = 'vertical', + length = 160, + breadth = 16, + segments = 28, + color = '#3df2ad', + zones, + offOpacity = 0.1, + peakHold = 1200, + peakColor, + faceColor = '#0b0d0e', + label, + showValue = false, + digits = 4, + displayDecimals = 1, + labelColor, + className, + style, + ...aria +}) => { + const theme = useKnobTheme(); + const vertical = orientation === 'vertical'; + const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1); + + // Peak: track the highest recent level, fall back after `peakHold` ms. + const [peak, setPeak] = React.useState(n); + React.useEffect(() => { + if (peakHold === false) return; + if (n >= peak) { + setPeak(n); + return; + } + const t = setTimeout(() => setPeak(n), peakHold); + return () => clearTimeout(t); + }, [n, peak, peakHold]); + + const pad = 4; + const w = vertical ? breadth : length; + const h = vertical ? length : breadth; + const zoneList: readonly LEDFaderZone[] = React.useMemo( + () => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]), + [zones, color], + ); + const zoneFor = (t: number): string => + (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; + + const lit = Math.round(n * segments); + const peakIdx = + peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1; + const slot = (length - pad * 2) / segments; + const gap = Math.min(2.5, slot * 0.35); + const cross = breadth - pad * 2; + + const leds: React.ReactNode[] = []; + for (let i = 0; i < segments; i++) { + const zone = zoneFor((i + 1) / segments); + const isPeak = i === peakIdx && i >= lit; + const on = i < lit || isPeak; + const fill = isPeak ? (peakColor ?? zone) : zone; + const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity }; + leds.push( + vertical ? ( + + ) : ( + + ), + ); + } + + return ( +
+ {showValue && ( +
+ +
+ )} + + {label && ( + + {label} + + )} +
+ ); +}; diff --git a/packages/dreamknob/src/skins/NeonKnob.tsx b/packages/dreamknob/src/skins/NeonKnob.tsx index 575d9f2..fb522cb 100644 --- a/packages/dreamknob/src/skins/NeonKnob.tsx +++ b/packages/dreamknob/src/skins/NeonKnob.tsx @@ -15,7 +15,7 @@ export interface NeonKnobProps extends SkinProps { } /** Minimal glowing arc knob — futuristic / dark-mode DAW style. */ -export const NeonKnob: React.FC = ({ +export const NeonKnob = React.forwardRef(function NeonKnob({ size = 84, color: colorProp, trackColor = 'rgba(255,255,255,0.06)', @@ -30,13 +30,13 @@ export const NeonKnob: React.FC = ({ className, style, ...core -}) => { +}, ref) { const theme = useKnobTheme(); const color = colorProp ?? theme.accent ?? '#e44cff'; const id = React.useId(); const glowId = `${id}-glow`; return ( - + = ({ )} ); -}; +}); diff --git a/packages/dreamknob/src/skins/RubberKnob.tsx b/packages/dreamknob/src/skins/RubberKnob.tsx index 4534d1e..bd70acc 100644 --- a/packages/dreamknob/src/skins/RubberKnob.tsx +++ b/packages/dreamknob/src/skins/RubberKnob.tsx @@ -17,7 +17,7 @@ export interface RubberKnobProps extends SkinProps { } /** Soft-touch rubber knob with a glowing halo — modern synth hardware. */ -export const RubberKnob: React.FC = ({ +export const RubberKnob = React.forwardRef(function RubberKnob({ size = 90, color: colorProp, trackColor, @@ -32,7 +32,7 @@ export const RubberKnob: React.FC = ({ className, style, ...core -}) => { +}, ref) { const theme = useKnobTheme(); const color = colorProp ?? theme.accent ?? '#ff9640'; const id = React.useId(); @@ -42,7 +42,7 @@ export const RubberKnob: React.FC = ({ const bodyR = size / 2 - size * 0.17; return ( - + @@ -110,4 +110,4 @@ export const RubberKnob: React.FC = ({ )} ); -}; +}); diff --git a/packages/dreamknob/src/skins/SteppedKnob.tsx b/packages/dreamknob/src/skins/SteppedKnob.tsx index 00e98aa..c6eb227 100644 --- a/packages/dreamknob/src/skins/SteppedKnob.tsx +++ b/packages/dreamknob/src/skins/SteppedKnob.tsx @@ -19,7 +19,7 @@ export interface SteppedKnobProps extends SkinProps { } /** Detented selector knob — mode/range switches with hard stops. */ -export const SteppedKnob: React.FC = ({ +export const SteppedKnob = React.forwardRef(function SteppedKnob({ size = 90, positions, steps, @@ -34,7 +34,7 @@ export const SteppedKnob: React.FC = ({ className, style, ...core -}) => { +}, ref) { const theme = useKnobTheme(); const color = colorProp ?? theme.accent ?? '#ffd23e'; const detents = positions ? positions.length : steps ?? 5; @@ -43,6 +43,7 @@ export const SteppedKnob: React.FC = ({ return ( = ({ }} ); -}; +}); diff --git a/packages/dreamknob/src/skins/VintageKnob.tsx b/packages/dreamknob/src/skins/VintageKnob.tsx index 11b0346..123a1f4 100644 --- a/packages/dreamknob/src/skins/VintageKnob.tsx +++ b/packages/dreamknob/src/skins/VintageKnob.tsx @@ -94,7 +94,7 @@ const ChickenHead: React.FC<{ bodyColor: string; indicatorColor: string }> = ({ }; /** Vintage amp-style knob: chicken-head pointer over a printed tick scale. */ -export const VintageKnob: React.FC = ({ +export const VintageKnob = React.forwardRef(function VintageKnob({ size = 96, bodyColor = '#efe6d0', scaleColor, @@ -107,11 +107,11 @@ export const VintageKnob: React.FC = ({ className, style, ...core -}) => { +}, ref) { const theme = useKnobTheme(); const scale = scaleColor ?? theme.ticks; return ( - + = ({ )} ); -}; +}); diff --git a/packages/dreamknob/src/skins/shared.ts b/packages/dreamknob/src/skins/shared.ts index 264c1a3..53706df 100644 --- a/packages/dreamknob/src/skins/shared.ts +++ b/packages/dreamknob/src/skins/shared.ts @@ -24,6 +24,8 @@ export interface SkinProps extends KnobCoreProps { editable?: boolean; /** Custom parser for typed input when `editable` is set. */ parseValue?: (text: string) => number | null; + /** Render a hidden form input carrying the current value. */ + name?: string; className?: string; style?: React.CSSProperties; }