From 5467ee79e29f51c8cf28398b45b3ff0a2fa6817b Mon Sep 17 00:00:00 2001 From: john Date: Sat, 17 Oct 2026 12:55:56 -0400 Subject: [PATCH] Angle dial: hover and type an angle Co-Authored-By: Claude Sonnet 5.5 --- src/components/AngleDial.tsx | 63 ++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/components/AngleDial.tsx b/src/components/AngleDial.tsx index 6aa31a6..f59f59b 100644 --- a/src/components/AngleDial.tsx +++ b/src/components/AngleDial.tsx @@ -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(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) { })} - - {value}° + + {typed ? `${typed}|` : `${value}°`} );