From 00c4ec8e00dcaa14db78b3fff5e0e64e9e9d9419 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 02:17:07 +0100 Subject: [PATCH] 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 --- src/components/Knob.jsx | 62 +++++++++++++++++++++++++++++++++++++---- src/index.css | 9 ++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/components/Knob.jsx b/src/components/Knob.jsx index 23d2c88..0d7cb73 100644 --- a/src/components/Knob.jsx +++ b/src/components/Knob.jsx @@ -1,4 +1,4 @@ -import React, { useRef, useCallback } from 'react'; +import React, { useRef, useCallback, useState } from 'react'; const SIZE = 32; 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 }) { const ref = 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 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(/\.$/, ''); const handlePointerDown = useCallback((e) => { + if (editing) return; e.preventDefault(); e.stopPropagation(); dragRef.current = { startY: e.clientY, startValue: value }; const handleMove = (me) => { @@ -47,7 +51,6 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent) const sensitivity = (max - min) / 200; let newVal = dragRef.current.startValue + dy * sensitivity; 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)) { 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('pointerup', handleUp); - }, [value, min, max, onChange]); + }, [value, min, max, onChange, editing]); const handleWheel = useCallback((e) => { + if (editing) return; e.preventDefault(); e.stopPropagation(); const step = (max - min) / 100; const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step)); 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 ( +
e.stopPropagation()}> + setEditText(e.target.value)} + onKeyDown={handleInputKeyDown} + onBlur={handleInputBlur} + onPointerDown={(e) => e.stopPropagation()} + /> +
+ ); + } return ( -
+
diff --git a/src/index.css b/src/index.css index 2dc1449..7507493 100644 --- a/src/index.css +++ b/src/index.css @@ -168,6 +168,15 @@ html, body, #root { .knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; } .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 { font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace; min-width: 40px; text-align: right;