Initial release: dreamknob library + showcase
React + TypeScript knob/fader library with a headless interaction core (rotary, relative-drag and track modes, wheel, keyboard, a11y), SVG primitives, nine prebuilt skins, seven-segment display engine, and a Vite showcase with gallery, live playground and API docs.
This commit is contained in:
commit
60a02cb1db
46 changed files with 6186 additions and 0 deletions
70
packages/dreamknob/src/primitives/Arc.tsx
Normal file
70
packages/dreamknob/src/primitives/Arc.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
import { describeArc } from '../core/math';
|
||||
|
||||
export interface ArcProps {
|
||||
/** Arc radius. Defaults to (size - thickness) / 2. */
|
||||
radius?: number;
|
||||
thickness?: number;
|
||||
/** Value arc color. */
|
||||
color?: string;
|
||||
/** Background track color. Omit to skip the track. */
|
||||
trackColor?: string;
|
||||
/** Draw the value arc from the minimum ('min') or from the travel center ('center', pan-style). */
|
||||
from?: 'min' | 'center';
|
||||
cap?: 'butt' | 'round';
|
||||
/** Extra SVG props for the value arc path (e.g. filter for glow). */
|
||||
arcProps?: React.SVGProps<SVGPathElement>;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
/** Background track + value arc following the knob's travel. */
|
||||
export const Arc: React.FC<ArcProps> = ({
|
||||
radius,
|
||||
thickness = 4,
|
||||
color = 'currentColor',
|
||||
trackColor,
|
||||
from = 'min',
|
||||
cap = 'round',
|
||||
arcProps,
|
||||
opacity,
|
||||
}) => {
|
||||
const { size, center, normalized, angleOffset, angleRange } = useKnobContext();
|
||||
const r = radius ?? (size - thickness) / 2;
|
||||
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (from === 'center') {
|
||||
const mid = angleOffset + angleRange / 2;
|
||||
const now = angleOffset + normalized * angleRange;
|
||||
start = Math.min(mid, now);
|
||||
end = Math.max(mid, now);
|
||||
} else {
|
||||
start = angleOffset;
|
||||
end = angleOffset + normalized * angleRange;
|
||||
}
|
||||
|
||||
return (
|
||||
<g opacity={opacity}>
|
||||
{trackColor && (
|
||||
<path
|
||||
d={describeArc(center, center, r, angleOffset, angleOffset + angleRange)}
|
||||
stroke={trackColor}
|
||||
strokeWidth={thickness}
|
||||
strokeLinecap={cap}
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
{end - start > 0.0001 && (
|
||||
<path
|
||||
d={describeArc(center, center, r, start, end)}
|
||||
stroke={color}
|
||||
strokeWidth={thickness}
|
||||
strokeLinecap={cap}
|
||||
fill="none"
|
||||
{...arcProps}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
13
packages/dreamknob/src/primitives/Face.tsx
Normal file
13
packages/dreamknob/src/primitives/Face.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
|
||||
export interface FaceProps extends React.SVGProps<SVGCircleElement> {
|
||||
/** Body radius. Defaults to size/2 - 6. */
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
/** The knob body: a centered circle. Style via fill/stroke/filter props. */
|
||||
export const Face: React.FC<FaceProps> = ({ radius, ...rest }) => {
|
||||
const { size, center } = useKnobContext();
|
||||
return <circle cx={center} cy={center} r={radius ?? size / 2 - 6} {...rest} />;
|
||||
};
|
||||
35
packages/dreamknob/src/primitives/GlowFilter.tsx
Normal file
35
packages/dreamknob/src/primitives/GlowFilter.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
|
||||
export interface GlowFilterProps {
|
||||
id: string;
|
||||
/** Gaussian blur radius in px. */
|
||||
blur: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* LED-style glow filter with a userSpaceOnUse region covering the whole
|
||||
* canvas. The default objectBoundingBox region collapses for thin stroked
|
||||
* paths (a straight line has a zero-area geometric bbox), which clips the
|
||||
* stroke and glow — this avoids that entirely.
|
||||
*/
|
||||
export const GlowFilter: React.FC<GlowFilterProps> = ({ id, blur }) => {
|
||||
const { size } = useKnobContext();
|
||||
const pad = size * 0.3;
|
||||
return (
|
||||
<filter
|
||||
id={id}
|
||||
filterUnits="userSpaceOnUse"
|
||||
x={-pad}
|
||||
y={-pad}
|
||||
width={size + pad * 2}
|
||||
height={size + pad * 2}
|
||||
>
|
||||
<feGaussianBlur stdDeviation={blur} result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
);
|
||||
};
|
||||
73
packages/dreamknob/src/primitives/Pointer.tsx
Normal file
73
packages/dreamknob/src/primitives/Pointer.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
|
||||
export interface PointerProps {
|
||||
/** Built-in shapes. Provide children instead for a custom pointer. */
|
||||
type?: 'line' | 'circle' | 'triangle';
|
||||
/** Distance from center to the pointer's outer end. Defaults to size/2 - 4. */
|
||||
radius?: number;
|
||||
/** Length of the pointer along the radial axis. */
|
||||
length?: number;
|
||||
width?: number;
|
||||
color?: string;
|
||||
cap?: 'butt' | 'round';
|
||||
/** Extra SVG props applied to the shape. */
|
||||
shapeProps?: React.SVGProps<SVGElement>;
|
||||
/** Custom pointer content, drawn pointing at 12 o'clock and rotated for you. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Rotating indicator. Children (or the built-in shape) are authored pointing up. */
|
||||
export const Pointer: React.FC<PointerProps> = ({
|
||||
type = 'line',
|
||||
radius,
|
||||
length = 12,
|
||||
width = 4,
|
||||
color = 'currentColor',
|
||||
cap = 'round',
|
||||
shapeProps,
|
||||
children,
|
||||
}) => {
|
||||
const { size, center, angle } = useKnobContext();
|
||||
const r = radius ?? size / 2 - 4;
|
||||
|
||||
let shape: React.ReactNode = children;
|
||||
if (!shape) {
|
||||
if (type === 'line') {
|
||||
shape = (
|
||||
<line
|
||||
x1={center}
|
||||
y1={center - r}
|
||||
x2={center}
|
||||
y2={center - r + length}
|
||||
stroke={color}
|
||||
strokeWidth={width}
|
||||
strokeLinecap={cap}
|
||||
{...(shapeProps as React.SVGProps<SVGLineElement>)}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'circle') {
|
||||
shape = (
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center - r + width / 2}
|
||||
r={width}
|
||||
fill={color}
|
||||
{...(shapeProps as React.SVGProps<SVGCircleElement>)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const half = width / 2;
|
||||
const tipY = center - r;
|
||||
shape = (
|
||||
<polygon
|
||||
points={`${center},${tipY} ${center - half},${tipY + length} ${center + half},${tipY + length}`}
|
||||
fill={color}
|
||||
{...(shapeProps as React.SVGProps<SVGPolygonElement>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <g transform={`rotate(${angle} ${center} ${center})`}>{shape}</g>;
|
||||
};
|
||||
97
packages/dreamknob/src/primitives/Text.tsx
Normal file
97
packages/dreamknob/src/primitives/Text.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
|
||||
/** "64.000" -> "64", "12.50" -> "12.5"; leaves integers untouched. */
|
||||
const trimZeros = (s: string): string =>
|
||||
s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
|
||||
|
||||
export interface KnobValueProps {
|
||||
/** Fixed decimal places for display. Default: the knob's `decimals`, with trailing zeros trimmed. */
|
||||
decimals?: number;
|
||||
/** Full custom formatter — takes precedence over `decimals` / `unit`. */
|
||||
format?: (value: number) => string;
|
||||
unit?: string;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fontFamily?: string;
|
||||
fontWeight?: number | string;
|
||||
/** Vertical offset from center. */
|
||||
dy?: number;
|
||||
textProps?: React.SVGProps<SVGTextElement>;
|
||||
}
|
||||
|
||||
/** Numeric readout in the middle of the knob. */
|
||||
export const KnobValue: React.FC<KnobValueProps> = ({
|
||||
decimals,
|
||||
format,
|
||||
unit,
|
||||
fontSize,
|
||||
color = 'currentColor',
|
||||
fontFamily,
|
||||
fontWeight = 600,
|
||||
dy = 0,
|
||||
textProps,
|
||||
}) => {
|
||||
const ctx = useKnobContext();
|
||||
const text = format
|
||||
? format(ctx.value)
|
||||
: `${
|
||||
decimals !== undefined
|
||||
? ctx.value.toFixed(decimals)
|
||||
: trimZeros(ctx.value.toFixed(ctx.decimals))
|
||||
}${unit ?? ''}`;
|
||||
return (
|
||||
<text
|
||||
x={ctx.center}
|
||||
y={ctx.center + dy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={fontSize ?? ctx.size * 0.18}
|
||||
fill={color}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={fontWeight}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
{...textProps}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
export interface KnobLabelProps {
|
||||
children: React.ReactNode;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fontFamily?: string;
|
||||
/** Vertical offset from center; defaults to just below the knob body. */
|
||||
dy?: number;
|
||||
textProps?: React.SVGProps<SVGTextElement>;
|
||||
}
|
||||
|
||||
/** Small caption, e.g. the parameter name. */
|
||||
export const KnobLabel: React.FC<KnobLabelProps> = ({
|
||||
children,
|
||||
fontSize,
|
||||
color = 'currentColor',
|
||||
fontFamily,
|
||||
dy,
|
||||
textProps,
|
||||
}) => {
|
||||
const { size, center } = useKnobContext();
|
||||
return (
|
||||
<text
|
||||
x={center}
|
||||
y={center + (dy ?? size * 0.38)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={fontSize ?? size * 0.12}
|
||||
fill={color}
|
||||
fontFamily={fontFamily}
|
||||
letterSpacing="0.08em"
|
||||
style={{ pointerEvents: 'none', textTransform: 'uppercase' }}
|
||||
{...textProps}
|
||||
>
|
||||
{children}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
55
packages/dreamknob/src/primitives/Ticks.tsx
Normal file
55
packages/dreamknob/src/primitives/Ticks.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
import { polarToCartesian } from '../core/math';
|
||||
|
||||
export interface TicksProps {
|
||||
count?: number;
|
||||
/** Outer radius of the ticks. Defaults to size/2. */
|
||||
radius?: number;
|
||||
length?: number;
|
||||
width?: number;
|
||||
color?: string;
|
||||
/** Color for ticks at or below the current position. Enables "lit" scales. */
|
||||
activeColor?: string;
|
||||
cap?: 'butt' | 'round';
|
||||
/** Extra props per tick line, by index. */
|
||||
getTickProps?: (index: number, active: boolean) => React.SVGProps<SVGLineElement> | undefined;
|
||||
}
|
||||
|
||||
/** Radial tick marks distributed across the knob's travel. */
|
||||
export const Ticks: React.FC<TicksProps> = ({
|
||||
count = 11,
|
||||
radius,
|
||||
length = 6,
|
||||
width = 2,
|
||||
color = 'currentColor',
|
||||
activeColor,
|
||||
cap = 'round',
|
||||
getTickProps,
|
||||
}) => {
|
||||
const { size, center, normalized, angleOffset, angleRange } = useKnobContext();
|
||||
const outer = radius ?? size / 2;
|
||||
const ticks: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = count === 1 ? 0 : i / (count - 1);
|
||||
const angle = angleOffset + t * angleRange;
|
||||
const p1 = polarToCartesian(center, center, outer, angle);
|
||||
const p2 = polarToCartesian(center, center, outer - length, angle);
|
||||
const active = t <= normalized + 1e-9;
|
||||
ticks.push(
|
||||
<line
|
||||
key={i}
|
||||
x1={p1.x}
|
||||
y1={p1.y}
|
||||
x2={p2.x}
|
||||
y2={p2.y}
|
||||
stroke={active && activeColor ? activeColor : color}
|
||||
strokeWidth={width}
|
||||
strokeLinecap={cap}
|
||||
{...getTickProps?.(i, active)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return <g>{ticks}</g>;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue