feat: double-click knobs to type exact values
Double-clicking a knob opens an inline text input for precise value entry. Enter confirms, Escape cancels, blur auto-commits. Value is clamped to the knob's min/max range. Styled to match the synth theme. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useCallback } from 'react';
|
import React, { useRef, useCallback, useState } from 'react';
|
||||||
|
|
||||||
const SIZE = 32;
|
const SIZE = 32;
|
||||||
const RADIUS = 12;
|
const RADIUS = 12;
|
||||||
@@ -22,6 +22,9 @@ function describeArc(cx, cy, r, startDeg, endDeg) {
|
|||||||
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue }) {
|
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue }) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const dragRef = useRef(null);
|
const dragRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editText, setEditText] = useState('');
|
||||||
|
|
||||||
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||||
const angleDeg = START_ANGLE - norm * RANGE;
|
const angleDeg = START_ANGLE - norm * RANGE;
|
||||||
@@ -40,6 +43,7 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
|
|||||||
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
|
||||||
const handlePointerDown = useCallback((e) => {
|
const handlePointerDown = useCallback((e) => {
|
||||||
|
if (editing) return;
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
dragRef.current = { startY: e.clientY, startValue: value };
|
dragRef.current = { startY: e.clientY, startValue: value };
|
||||||
const handleMove = (me) => {
|
const handleMove = (me) => {
|
||||||
@@ -47,7 +51,6 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
|
|||||||
const sensitivity = (max - min) / 200;
|
const sensitivity = (max - min) / 200;
|
||||||
let newVal = dragRef.current.startValue + dy * sensitivity;
|
let newVal = dragRef.current.startValue + dy * sensitivity;
|
||||||
newVal = Math.max(min, Math.min(max, newVal));
|
newVal = Math.max(min, Math.min(max, newVal));
|
||||||
// Snap to nice values for integer ranges
|
|
||||||
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
|
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
|
||||||
newVal = Math.round(newVal);
|
newVal = Math.round(newVal);
|
||||||
}
|
}
|
||||||
@@ -60,17 +63,66 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
|
|||||||
};
|
};
|
||||||
window.addEventListener('pointermove', handleMove);
|
window.addEventListener('pointermove', handleMove);
|
||||||
window.addEventListener('pointerup', handleUp);
|
window.addEventListener('pointerup', handleUp);
|
||||||
}, [value, min, max, onChange]);
|
}, [value, min, max, onChange, editing]);
|
||||||
|
|
||||||
const handleWheel = useCallback((e) => {
|
const handleWheel = useCallback((e) => {
|
||||||
|
if (editing) return;
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
const step = (max - min) / 100;
|
const step = (max - min) / 100;
|
||||||
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
|
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
|
||||||
onChange(newVal);
|
onChange(newVal);
|
||||||
}, [value, min, max, onChange]);
|
}, [value, min, max, onChange, editing]);
|
||||||
|
|
||||||
|
// Double-click: open inline text input
|
||||||
|
const handleDoubleClick = useCallback((e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
setEditText(String(typeof displayVal === 'number' ? displayVal : value));
|
||||||
|
setEditing(true);
|
||||||
|
// Focus input after render
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}, [value, displayVal]);
|
||||||
|
|
||||||
|
const commitEdit = useCallback(() => {
|
||||||
|
const parsed = parseFloat(editText);
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
const clamped = Math.max(min, Math.min(max, parsed));
|
||||||
|
onChange(clamped);
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [editText, min, max, onChange]);
|
||||||
|
|
||||||
|
const handleInputKeyDown = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
commitEdit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}, [commitEdit]);
|
||||||
|
|
||||||
|
const handleInputBlur = useCallback(() => {
|
||||||
|
commitEdit();
|
||||||
|
}, [commitEdit]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<div className="knob-container knob-editing" onWheel={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="knob-input"
|
||||||
|
type="text"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="knob-container" onWheel={handleWheel}>
|
<div className="knob-container" onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
|
||||||
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
onPointerDown={handlePointerDown} ref={ref}>
|
onPointerDown={handlePointerDown} ref={ref}>
|
||||||
<path className="knob-track" d={trackPath} />
|
<path className="knob-track" d={trackPath} />
|
||||||
|
|||||||
@@ -168,6 +168,15 @@ html, body, #root {
|
|||||||
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
|
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
|
||||||
.knob-dot { fill: var(--text); }
|
.knob-dot { fill: var(--text); }
|
||||||
|
|
||||||
|
.knob-editing { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.knob-input {
|
||||||
|
width: 48px; height: 22px; padding: 0 4px;
|
||||||
|
background: var(--bg); border: 1px solid var(--accent); border-radius: 3px;
|
||||||
|
color: var(--accent); font-size: 11px; font-family: 'JetBrains Mono', monospace;
|
||||||
|
text-align: center; outline: none;
|
||||||
|
}
|
||||||
|
.knob-input:focus { box-shadow: 0 0 6px rgba(0,229,255,0.3); }
|
||||||
|
|
||||||
.param-value {
|
.param-value {
|
||||||
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
|
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
|
||||||
min-width: 40px; text-align: right;
|
min-width: 40px; text-align: right;
|
||||||
|
|||||||
Reference in New Issue
Block a user