- origin: bipolar anchor value — Arc from=center, Fader fill and LEDFader segments draw from origin instead of min/mid-travel - detents + detentSize: magnetic snap while dragging (normalized space, wheel/keyboard unaffected); applyDetents exported - DreamknobProvider + KnobTheme: accent/track/face/text/label/ticks/ focusRing/fonts tokens with dark and light bases; every skin resolves defaults from the theme, instance props win - editable + parseValue: double-click knobs (or click fader readouts) to type exact values; Enter/blur commits, Esc cancels, 1.2k parses; ValueInput + defaultParseValue exported; setValue added to context - ImageKnob: film-strip sprite knob (KnobMan-style strips), vertical or horizontal, with focus ring, editing, value/label - Docs: LED gallery cards for film-strip/bipolar/editing/theming, playground editable toggle, theming API section, README updates
120 lines
3.7 KiB
TypeScript
120 lines
3.7 KiB
TypeScript
import * as React from 'react';
|
|
import { KnobContextProvider, type KnobRenderContext } from '../core/context';
|
|
import { useKnobTheme } from '../core/theme';
|
|
import type { KnobCoreProps } from '../core/types';
|
|
import { useKnob } from '../hooks/useKnob';
|
|
import { ValueInput, defaultParseValue } from './ValueInput';
|
|
|
|
export interface KnobProps extends KnobCoreProps {
|
|
/** Square canvas size in px. Default: 80. */
|
|
size?: number;
|
|
/**
|
|
* Color of the keyboard focus ring, or `false` to disable it (only do this
|
|
* if you render your own focus indicator). Default: the theme's ring.
|
|
*/
|
|
focusRing?: string | false;
|
|
/**
|
|
* Double-click opens a type-in editor instead of resetting: type an exact
|
|
* value, Enter/blur commits, Escape cancels. Accepts "1.2k" style input.
|
|
*/
|
|
editable?: boolean;
|
|
/** Custom parser for typed input, e.g. to accept "-6 dB" or "A4". */
|
|
parseValue?: (text: string) => number | null;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
|
|
children?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode);
|
|
/** Extra content rendered inside the wrapper but outside the SVG (e.g. HTML labels). */
|
|
overlay?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode);
|
|
}
|
|
|
|
/**
|
|
* Headless-but-visual knob container: wires up the full interaction model
|
|
* (rotary/relative/track drag, wheel, keyboard, double-click reset, a11y)
|
|
* and provides a render context for composable SVG primitives.
|
|
*/
|
|
export const Knob: React.FC<KnobProps> = ({
|
|
size = 80,
|
|
focusRing,
|
|
editable = false,
|
|
parseValue,
|
|
className,
|
|
style,
|
|
children,
|
|
overlay,
|
|
...core
|
|
}) => {
|
|
const theme = useKnobTheme();
|
|
const ring = focusRing ?? theme.focusRing;
|
|
const knob = useKnob(core);
|
|
const [editing, setEditing] = React.useState(false);
|
|
|
|
const closeEditor = () => {
|
|
setEditing(false);
|
|
knob.ref.current?.focus({ preventScroll: true });
|
|
};
|
|
const onDoubleClick = editable
|
|
? (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setEditing(true);
|
|
}
|
|
: knob.bind.onDoubleClick;
|
|
|
|
const ctx: KnobRenderContext = {
|
|
value: knob.value,
|
|
normalized: knob.normalized,
|
|
originNormalized: knob.originNormalized,
|
|
angle: knob.angle,
|
|
isDragging: knob.isDragging,
|
|
min: knob.min,
|
|
max: knob.max,
|
|
decimals: knob.decimals,
|
|
angleOffset: knob.angleOffset,
|
|
angleRange: knob.angleRange,
|
|
size,
|
|
center: size / 2,
|
|
setValue: knob.setValue,
|
|
};
|
|
|
|
return (
|
|
<KnobContextProvider value={ctx}>
|
|
<div
|
|
{...knob.bind}
|
|
onDoubleClick={onDoubleClick}
|
|
className={className}
|
|
style={{
|
|
display: 'inline-flex',
|
|
position: 'relative',
|
|
width: size,
|
|
height: size,
|
|
borderRadius: '50%',
|
|
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
|
...knob.bind.style,
|
|
...style,
|
|
}}
|
|
>
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
style={{ display: 'block', overflow: 'visible' }}
|
|
aria-hidden="true"
|
|
>
|
|
{typeof children === 'function' ? children(ctx) : children}
|
|
</svg>
|
|
{typeof overlay === 'function' ? overlay(ctx) : overlay}
|
|
{editing && (
|
|
<ValueInput
|
|
initial={String(knob.value)}
|
|
onCommit={raw => {
|
|
const parsed = (parseValue ?? defaultParseValue)(raw);
|
|
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
|
closeEditor();
|
|
}}
|
|
onCancel={closeEditor}
|
|
/>
|
|
)}
|
|
</div>
|
|
</KnobContextProvider>
|
|
);
|
|
};
|