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 {
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user