From 8c92def919e2287c726d0e6739532454e1925622 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 17 Oct 2026 12:46:16 -0400 Subject: [PATCH] Hover-and-type number entry on wheels Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- src/components/Wheel.tsx | 93 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 142b34e..a0ebe69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Layout and editing UI. No optimizer yet. - Room in the center (fixed 10m x 8m), scaled to fill the space (`pxPerM` from `fitScale`, recomputed by a ResizeObserver in `App.tsx`), with a "1 m" scale bar. No grid, NO snapping: items land exactly where dropped. - Drag a tile into the room to create an item (tile stays). Click (<5px movement) a tile to open its type menu (takes over the sidebar; Escape / Back closes): "Available" wheel (min = placed) and, if the type has seats, a seats wheel. Badge = available - placed; at 0 the tile greys out and can't be dragged. - Items are free-placed, clamped to the room, no overlap (touching is fine). Red outline while the position is invalid, green when valid; invalid drop snaps back (or cancels a new add). -- Wheels (`components/Wheel.tsx`) are iOS-timer style: 1:1 drag, momentum fling, press to stop, spring settle, mouse wheel/trackpad, tap a row, arrow keys. Props: `step`, `wrap`, `rows`, `disabled`, `format`. If the parent rejects a change the wheel glides back to the real value. +- Wheels (`components/Wheel.tsx`) are iOS-timer style: 1:1 drag, momentum fling, press to stop, spring settle, mouse wheel/trackpad, tap a row, arrow keys, and hover + type a number (Enter, leaving, or 1.5 s idle commits; Escape cancels). Props: `step`, `wrap`, `rows`, `disabled`, `format`. If the parent rejects a change the wheel glides back to the real value. **Editing (Phase 2)** - Click an item to select it (blue outline); click empty floor to deselect (Escape/Done too). Dragging still moves. Selecting opens the item panel in the sidebar (replaces tiles / type menu). diff --git a/src/components/Wheel.tsx b/src/components/Wheel.tsx index 7202a76..4bcbc4a 100644 --- a/src/components/Wheel.tsx +++ b/src/components/Wheel.tsx @@ -33,6 +33,8 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis const [pos, setPos] = useState(idx(value)); const [dragging, setDragging] = useState(false); + const [hover, setHover] = useState(false); + const [typed, setTyped] = useState(""); // digits typed while hovering const posRef = useRef(idx(value)); // fractional position in notches const lastEmitted = useRef(idx(value)); @@ -160,6 +162,67 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis useEffect(() => stop, []); + // Hover + type a number. Enter / leaving / 1.5 s idle commits, Escape cancels. + const typedRef = useRef(""); + const typeTimer = useRef(0); + const commitTyped = () => { + window.clearTimeout(typeTimer.current); + const text = typedRef.current; + typedRef.current = ""; + setTyped(""); + const num = parseFloat(text); + if (Number.isNaN(num)) return; + const c = cfg.current; + let i = Math.round(num / c.step); + if (c.wrap) { + // go the short way round to the typed angle + const d = wrapIdx(i) - wrapIdx(Math.round(posRef.current)); + i = Math.round(posRef.current) + (Math.abs(d) > c.n / 2 ? d - Math.sign(d) * c.n : d); + } else { + i = Math.min(c.imax, Math.max(c.imin, i)); + } + animateTo(i); + }; + const setTypedText = (t: string) => { + typedRef.current = t; + setTyped(t); + window.clearTimeout(typeTimer.current); + if (t) typeTimer.current = window.setTimeout(commitTyped, 1500); + }; + + 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) || (e.key === "." && decimals > 0 && !buf.includes("."))) { + swallow(); + if (buf.length < 6) { + stop(); + setTypedText(buf + e.key); + } + } else if (e.key === "Backspace" || e.key === "Delete") { + swallow(); + setTypedText(buf.slice(0, -1)); + } else if (e.key === "Enter" && buf) { + swallow(); + commitTyped(); + } else if (e.key === "Escape" && buf) { + swallow(); + setTypedText(""); + } + }; + window.addEventListener("keydown", key, true); + return () => window.removeEventListener("keydown", key, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hover, disabled, decimals]); + + useEffect(() => () => window.clearTimeout(typeTimer.current), []); + const onPointerDown = (e: React.PointerEvent) => { if (disabled) return; e.currentTarget.setPointerCapture(e.pointerId); @@ -285,6 +348,12 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis onPointerCancel={onPointerUp} onWheel={onWheel} onKeyDown={onKeyDown} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => { + setHover(false); + if (typedRef.current) commitTyped(); + }} + title={disabled ? undefined : "Scroll, drag, or hover and type a number"} style={{ position: "relative", width: "100%", @@ -312,6 +381,30 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis }} /> {items} + {typed && ( +
+ {typed} + +
+ )} ); }