Hover-and-type number entry on wheels
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- 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.
|
- 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).
|
- 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)**
|
**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).
|
- 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).
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis
|
|||||||
|
|
||||||
const [pos, setPos] = useState(idx(value));
|
const [pos, setPos] = useState(idx(value));
|
||||||
const [dragging, setDragging] = useState(false);
|
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 posRef = useRef(idx(value)); // fractional position in notches
|
||||||
const lastEmitted = useRef(idx(value));
|
const lastEmitted = useRef(idx(value));
|
||||||
@@ -160,6 +162,67 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis
|
|||||||
|
|
||||||
useEffect(() => stop, []);
|
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) => {
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
@@ -285,6 +348,12 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis
|
|||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
onMouseEnter={() => setHover(true)}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHover(false);
|
||||||
|
if (typedRef.current) commitTyped();
|
||||||
|
}}
|
||||||
|
title={disabled ? undefined : "Scroll, drag, or hover and type a number"}
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -312,6 +381,30 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{items}
|
{items}
|
||||||
|
{typed && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 6,
|
||||||
|
right: 6,
|
||||||
|
top: ((visible - 1) / 2) * ROW_H,
|
||||||
|
height: ROW_H,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1.5px solid #1c7ed6",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
font: "600 20px system-ui, sans-serif",
|
||||||
|
color: "#1c7ed6",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typed}
|
||||||
|
<span style={{ width: 1.5, height: 20, background: "#1c7ed6", marginLeft: 2 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user