diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx index 32d5509..12d3dbb 100644 --- a/apps/docs/src/sections/ApiDocs.tsx +++ b/apps/docs/src/sections/ApiDocs.tsx @@ -52,6 +52,9 @@ export const ApiDocs: React.FC = () => ( + + + @@ -95,6 +98,9 @@ export const ApiDocs: React.FC = () => ( + + +

diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx index 68977af..5782530 100644 --- a/apps/docs/src/sections/Gallery.tsx +++ b/apps/docs/src/sections/Gallery.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { + AlphaDisplay, Arc, DreamknobProvider, Fader, @@ -13,14 +14,57 @@ import { Meter, NeonKnob, Pointer, + PushButton, RubberKnob, SegmentDisplay, SteppedKnob, Ticks, VintageKnob, + XYPad, logTaper, } 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 ( +

+
+ undefined} + readOnly + animateChanges={{ duration: 350 }} + color="#3df2ad" + label="Cutoff" + aria-label="Preset cutoff" + /> + +
+
+ {PRESETS.map((p, i) => ( + on && setPreset(i)} + color="#3df2ad" + size={34} + aria-label={`Preset ${p.name}`} + > + {String.fromCharCode(65 + i)} + + ))} +
+
+ ); +}; + /** Draw a film-strip of a simple hardware knob at runtime (stand-in for a KnobMan PNG). */ const useGeneratedStrip = (frames = 31, fs = 120): string | null => { const [src, setSrc] = React.useState(null); @@ -340,6 +384,59 @@ export const Gallery: React.FC = () => ( /> + + + + + + (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)} + color="#e44cff" + label="Filter" + aria-label="Filter XY pad" + /> + + + + + + + + `${v} %`} + label="Blend" + aria-label="Bubble demo" + /> + + + +
+ + +
+
+ diff --git a/packages/dreamknob/README.md b/packages/dreamknob/README.md index bc7c94b..febe38e 100644 --- a/packages/dreamknob/README.md +++ b/packages/dreamknob/README.md @@ -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. - `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks with `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 `
` posts; every component forwards a 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 ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips) 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 + AlphaDisplay, // fourteen-segment alphanumeric display } from 'dreamknob' ``` diff --git a/packages/dreamknob/src/components/Knob.tsx b/packages/dreamknob/src/components/Knob.tsx index 1ed87c8..ca171c8 100644 --- a/packages/dreamknob/src/components/Knob.tsx +++ b/packages/dreamknob/src/components/Knob.tsx @@ -22,6 +22,10 @@ export interface KnobProps extends KnobCoreProps { parseValue?: (text: string) => number | null; /** Render a hidden form input carrying the current value. */ 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; style?: React.CSSProperties; /** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */ @@ -42,6 +46,8 @@ export const Knob = React.forwardRef(function Knob( editable = false, parseValue, name, + valueBubble = false, + bubbleFormat, className, style, children, @@ -128,7 +134,36 @@ export const Knob = React.forwardRef(function Knob( /> )} {name && } + {valueBubble && knob.isDragging && ( +
+ {(bubbleFormat ?? trimValue(knob.decimals))(knob.value)} +
+ )} ); }); + +const trimValue = + (decimals: number) => + (v: number): string => { + const s = v.toFixed(decimals); + return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s; + }; diff --git a/packages/dreamknob/src/core/types.ts b/packages/dreamknob/src/core/types.ts index 54912d6..4afa903 100644 --- a/packages/dreamknob/src/core/types.ts +++ b/packages/dreamknob/src/core/types.ts @@ -67,6 +67,19 @@ export interface KnobCoreProps { detents?: readonly number[]; /** Detent capture radius as a fraction of travel. Default: 0.025. */ 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'. */ interaction?: InteractionMode; /** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */ diff --git a/packages/dreamknob/src/digital/AlphaDisplay.tsx b/packages/dreamknob/src/digital/AlphaDisplay.tsx new file mode 100644 index 0000000..0281463 --- /dev/null +++ b/packages/dreamknob/src/digital/AlphaDisplay.tsx @@ -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> = { + 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> = { + 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 = { + '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 = ({ + 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( + , + ); + 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(); + } else { + const [x1, y1, x2, y2] = DIAG[seg]!; + parts.push( + , + ); + } + } + cells.push( + + {parts} + , + ); + 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 ( + + {glow > 0 && ( + + + + + + + + + + )} + {background !== 'none' && } + 0 ? `url(#${filterId})` : undefined} + > + {skew ? {cells} : cells} + + + ); +}; diff --git a/packages/dreamknob/src/hooks/useKnob.ts b/packages/dreamknob/src/hooks/useKnob.ts index 45e202e..945c6fc 100644 --- a/packages/dreamknob/src/hooks/useKnob.ts +++ b/packages/dreamknob/src/hooks/useKnob.ts @@ -92,6 +92,8 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { origin, detents, detentSize = 0.025, + wrap = false, + animateChanges = false, fineMultiplier = 0.1, angleOffset = 225, angleRange = 270, @@ -116,10 +118,16 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { props.doubleClickReset ?? props.defaultValue !== undefined; 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( (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) { v = findClosest(values, v); } else if (steps && steps > 1) { @@ -131,7 +139,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { } return roundTo(v, decimals); }, - [min, max, step, values, steps, taper, decimals], + [min, max, step, values, steps, taper, decimals, wrap], ); const isControlled = props.value !== undefined; @@ -167,10 +175,14 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { [constrain, isControlled, onChange], ); + const wrapN = React.useCallback( + (n: number) => (wrap ? ((n % 1) + 1) % 1 : clamp(n, 0, 1)), + [wrap], + ); const setFromNormalized = React.useCallback( (n: number, source: ChangeSource = 'api') => - emit(taper.fromNormalized(clamp(n, 0, 1), min, max), source), - [emit, taper, min, max], + emit(taper.fromNormalized(wrapN(n), min, max), source), + [emit, taper, min, max, wrapN], ); // 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; 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); } s.prevAngle = angle; @@ -257,7 +269,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { if (interaction === 'horizontal' || interaction === 'both') dn += (clientX - s.lastX) * scale; // 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); } } @@ -275,6 +287,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { trackInset, fineMultiplier, setFromDrag, + wrapN, ], ); @@ -530,7 +543,49 @@ export function useKnob(props: KnobCoreProps): UseKnobResult { onChangeEndRef.current?.(target, { source: 'reset' }); }, [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(); + + 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 originNormalized = origin === undefined diff --git a/packages/dreamknob/src/index.ts b/packages/dreamknob/src/index.ts index 60f9127..4c34590 100644 --- a/packages/dreamknob/src/index.ts +++ b/packages/dreamknob/src/index.ts @@ -49,6 +49,7 @@ export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from ' // Digital export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay'; +export { AlphaDisplay, type AlphaDisplayProps } from './digital/AlphaDisplay'; // Value editing 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 { ImageKnob, type ImageKnobProps } from './skins/ImageKnob'; 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'; diff --git a/packages/dreamknob/src/skins/ImageKnob.tsx b/packages/dreamknob/src/skins/ImageKnob.tsx index cf2fa51..8edadc5 100644 --- a/packages/dreamknob/src/skins/ImageKnob.tsx +++ b/packages/dreamknob/src/skins/ImageKnob.tsx @@ -24,6 +24,9 @@ export interface ImageKnobProps extends KnobCoreProps { /** Double-click to type an exact value. */ editable?: boolean; 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). */ imageStyle?: React.CSSProperties; className?: string; @@ -48,6 +51,8 @@ export const ImageKnob = React.forwardRef(functi focusRing, editable = false, parseValue, + valueBubble = false, + bubbleFormat, imageStyle, className, style, @@ -131,6 +136,28 @@ export const ImageKnob = React.forwardRef(functi onCancel={closeEditor} /> )} + {valueBubble && knob.isDragging && ( +
+ {(bubbleFormat ?? (() => text))(knob.value)} +
+ )} {showValue && (
diff --git a/packages/dreamknob/src/skins/PushButton.tsx b/packages/dreamknob/src/skins/PushButton.tsx new file mode 100644 index 0000000..f6039da --- /dev/null +++ b/packages/dreamknob/src/skins/PushButton.tsx @@ -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( + 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 ( + + ); + }, +); diff --git a/packages/dreamknob/src/skins/XYPad.tsx b/packages/dreamknob/src/skins/XYPad.tsx new file mode 100644 index 0000000..71988a9 --- /dev/null +++ b/packages/dreamknob/src/skins/XYPad.tsx @@ -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(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({ + 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(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( + , + , + ); + } + + const ring = focusRing ?? theme.focusRing; + const text = `${formatX ? formatX(pos.x) : trim(pos.x, dx)} · ${formatY ? formatY(pos.y) : trim(pos.y, dy)}`; + + return ( +
+ {showValue && ( +
+ + {text} + +
+ )} +
{ + (rootRef as React.MutableRefObject).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), + }} + > + + {nameX && } + {nameY && } +
+ {label && ( + + {label} + + )} +
+ ); +}); diff --git a/packages/dreamknob/src/skins/shared.ts b/packages/dreamknob/src/skins/shared.ts index 53706df..ddadc36 100644 --- a/packages/dreamknob/src/skins/shared.ts +++ b/packages/dreamknob/src/skins/shared.ts @@ -26,6 +26,10 @@ export interface SkinProps extends KnobCoreProps { parseValue?: (text: string) => number | null; /** Render a hidden form input carrying the current value. */ 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; style?: React.CSSProperties; }