Angle dial: hover and type an angle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
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 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 angleAt = (e: React.PointerEvent) => {
|
||||
@@ -54,6 +106,11 @@ export function AngleDial({ value, disabled = false, onChange }: Props) {
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={359}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => {
|
||||
setHover(false);
|
||||
if (typedRef.current) commit();
|
||||
}}
|
||||
onPointerDown={down}
|
||||
onPointerMove={move}
|
||||
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} />
|
||||
<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" }}>
|
||||
{value}°
|
||||
<text x={c} y={c + 6} textAnchor="middle" style={{ font: "600 18px system-ui, sans-serif", fill: typed ? "#1c7ed6" : "#222" }}>
|
||||
{typed ? `${typed}|` : `${value}°`}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user