Angle dial: hover and type an angle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 12:55:56 -04:00
parent b6eaeda028
commit 5467ee79e2
+60 -3
View File
@@ -1,4 +1,4 @@
import { useRef } from "react"; import { useEffect, useRef, useState } from "react";
interface Props { interface Props {
value: number; // degrees, 0 = knob at the top, clockwise value: number; // degrees, 0 = knob at the top, clockwise
@@ -14,6 +14,58 @@ export function AngleDial({ value, disabled = false, onChange }: Props) {
const box = useRef<SVGSVGElement>(null); const box = useRef<SVGSVGElement>(null);
const dragging = useRef(false); const dragging = useRef(false);
// Hover + type an angle. Enter / leaving / 1.5 s idle commits, Escape cancels.
const [hover, setHover] = useState(false);
const [typed, setTyped] = useState("");
const typedRef = useRef("");
const timer = useRef(0);
const latest = useRef({ onChange });
latest.current = { onChange };
const setText = (t: string) => {
typedRef.current = t;
setTyped(t);
window.clearTimeout(timer.current);
if (t) timer.current = window.setTimeout(commit, 1500);
};
function commit() {
window.clearTimeout(timer.current);
const n = parseInt(typedRef.current, 10);
typedRef.current = "";
setTyped("");
if (!Number.isNaN(n)) latest.current.onChange(((n % 360) + 360) % 360);
}
useEffect(() => {
if (!hover || disabled) return;
const key = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const buf = typedRef.current;
const swallow = () => {
e.preventDefault();
e.stopImmediatePropagation(); // keep Backspace from deleting the selected item
};
if (/^[0-9]$/.test(e.key)) {
swallow();
if (buf.length < 3) setText(buf + e.key);
} else if (e.key === "Backspace" || e.key === "Delete") {
swallow();
setText(buf.slice(0, -1));
} else if (e.key === "Enter" && buf) {
swallow();
commit();
} else if (e.key === "Escape" && buf) {
swallow();
setText("");
}
};
window.addEventListener("keydown", key, true);
return () => window.removeEventListener("keydown", key, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hover, disabled]);
useEffect(() => () => window.clearTimeout(timer.current), []);
const norm = (d: number) => ((Math.round(d) % 360) + 360) % 360; const norm = (d: number) => ((Math.round(d) % 360) + 360) % 360;
const angleAt = (e: React.PointerEvent) => { const angleAt = (e: React.PointerEvent) => {
@@ -54,6 +106,11 @@ export function AngleDial({ value, disabled = false, onChange }: Props) {
aria-valuenow={value} aria-valuenow={value}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={359} aria-valuemax={359}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => {
setHover(false);
if (typedRef.current) commit();
}}
onPointerDown={down} onPointerDown={down}
onPointerMove={move} onPointerMove={move}
onPointerUp={up} onPointerUp={up}
@@ -75,8 +132,8 @@ export function AngleDial({ value, disabled = false, onChange }: Props) {
})} })}
<line x1={c} y1={c} x2={kx} y2={ky} stroke="#1c7ed6" strokeWidth={2} opacity={0.5} /> <line x1={c} y1={c} x2={kx} y2={ky} stroke="#1c7ed6" strokeWidth={2} opacity={0.5} />
<circle cx={kx} cy={ky} r={9} fill="#1c7ed6" stroke="#fff" strokeWidth={2} /> <circle cx={kx} cy={ky} r={9} fill="#1c7ed6" stroke="#fff" strokeWidth={2} />
<text x={c} y={c + 6} textAnchor="middle" style={{ font: "600 18px system-ui, sans-serif", fill: "#222" }}> <text x={c} y={c + 6} textAnchor="middle" style={{ font: "600 18px system-ui, sans-serif", fill: typed ? "#1c7ed6" : "#222" }}>
{value}° {typed ? `${typed}|` : `${value}°`}
</text> </text>
</svg> </svg>
); );