diff --git a/CLAUDE.md b/CLAUDE.md index 591b87d..df0d97e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,13 +180,32 @@ src/domain/ search.ts the Find tab's matcher storage.ts the persisted blob, backup parsing, defaults +src/domain/ + rules.ts GENERATED — the nine rules and the SHIFT + pad map + src/ui/ - panelLayout.ts where every control sits; captions as silkscreened - Panel.tsx the panel, presentational: told what is lit, reports presses - cx.ts joins class names, dropping blanks + panelLayout.ts where every control sits; captions as silkscreened + Panel.tsx the panel: told what is lit, owns pointer bookkeeping + drillReducer.ts drill transitions, pure and React-free + persistedState.ts the only code that touches localStorage + Sheet.tsx bottom sheet, shared by both pickers and drill settings + InfoCard.tsx Explore's card · DrillStrip/DrillCard Drill's + FindPanel.tsx search and results · RulesPanel.tsx the rules page + cx.ts joins class names, dropping blanks src/styles/base.css tokens: palette, fonts, the vertical budget ``` +**Multitouch lives in `Panel`, meaning in `App`.** Answering a drill card means +holding two or three controls at once, so `Panel` tracks pointers per +`pointerId` and reports `onPress` per control plus `onGestureEnd` when the last +finger lifts. What a chord *means* stays in `App`: hold mode grades on +`onGestureEnd`, tap mode accumulates via `press` and waits for Check. + +Two things make it work and are easy to delete by accident: `touch-action: none` +on the panel, without which the browser claims the second finger as a pinch and +no chord can ever form; and listening for `pointerup` on the **window**, because +fingers routinely lift outside the key they went down on. + **CSS modules type as `string | undefined`** under `noUncheckedIndexedAccess`, so `cx()` absorbs that rather than the flag being turned off — it has caught real bugs. The cost is that a mistyped class renders with no class at all rather than diff --git a/src/App.tsx b/src/App.tsx index 16460e9..5fa5fd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -191,10 +191,17 @@ export function App() { lit={isDrill ? drillLit : exploreLit} held={isDrill ? drill.chord : activeLayer === "" ? [] : activeLayer.split("+")} selected={isDrill ? null : (selected?.tset[0] ?? null)} + onGestureEnd={(ids) => { + // Hold-to-answer grades when the last finger lifts. Tap mode waits for + // the Check button instead, and Explore has nothing to grade. + if (isDrill && drill.input === "hold" && !drill.answered) { + dispatch({ type: "release", chord: ids }); + } + }} onPress={(id) => { if (isDrill) { if (drill.answered) deal(); - else dispatch({ type: "press", id }); + else if (drill.input === "tap") dispatch({ type: "press", id }); return; } const here = exploreLit.get(id); diff --git a/src/ui/Panel.module.css b/src/ui/Panel.module.css index b2d7e5e..592125b 100644 --- a/src/ui/Panel.module.css +++ b/src/ui/Panel.module.css @@ -8,6 +8,17 @@ box-shadow: var(--shadow); display: flex; flex-direction: column; + /* Without this the browser claims a second finger as a pinch or a pan, and a + chord can never be formed. This is what makes multitouch work at all. */ + touch-action: none; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + +/* Pressed, whether or not it is lit. */ +.down { + transform: scale(0.95); } /* ---- shared key look ---- */ diff --git a/src/ui/Panel.tsx b/src/ui/Panel.tsx index b4410ff..9685b09 100644 --- a/src/ui/Panel.tsx +++ b/src/ui/Panel.tsx @@ -1,7 +1,13 @@ /** - * The SP-404MK2 panel. Presentational: it is told what is lit and reports - * presses, holding no state of its own. + * The SP-404MK2 panel. Presentational: it is told what is lit, and it reports + * presses. It owns pointer bookkeeping only because it owns the DOM — what a + * chord *means* stays in App. + * + * Multitouch matters here: answering a drill card means holding two or three + * controls at once. Pointers are tracked per pointerId, and the gesture ends + * when the last finger lifts, which is when a held chord is graded. */ +import { useCallback, useEffect, useRef, useState } from "react"; import { panelLabel } from "../domain/format"; import type { ControlId, Entry } from "../domain/types"; import { cx } from "./cx"; @@ -12,33 +18,34 @@ export interface ScreenText { readonly top: string; readonly right: string; readonly main: string; - /** Inverted chips along the bottom of the LCD — the layer's functions. */ readonly chips: readonly string[]; } export interface PanelProps { - /** Controls carrying a function on the current layer, and which entries. */ readonly lit: ReadonlyMap; readonly held: readonly ControlId[]; readonly selected?: ControlId | null; readonly screen: ScreenText; + /** A control went down. */ readonly onPress?: ((id: ControlId) => void) | undefined; + /** The last finger lifted; every control touched during the gesture. */ + readonly onGestureEnd?: ((ids: ControlId[]) => void) | undefined; } interface KeyProps { readonly id: ControlId; - /** Comes from a CSS module, so possibly undefined. See cx. */ readonly className: string | undefined; readonly lit: ReadonlyMap; readonly held: readonly ControlId[]; readonly selected: ControlId | null; - readonly onPress?: ((id: ControlId) => void) | undefined; + readonly down: ReadonlySet; + readonly onKey: (id: ControlId) => void; } /** Pads print their number, not "PAD 7" — until a layer lights them up. */ const padNumber = (id: ControlId): string | undefined => /^p(\d+)$/.exec(id)?.[1]; -function Key({ id, className, lit, held, selected, onPress }: KeyProps) { +function Key({ id, className, lit, held, selected, down, onKey }: KeyProps) { const entries = lit.get(id); const isHeld = held.includes(id); const isLit = entries !== undefined && entries.length > 0; @@ -50,6 +57,7 @@ function Key({ id, className, lit, held, selected, onPress }: KeyProps) { type="button" className={cx( className, + down.has(id) && styles.down, isHeld && styles.held, !isHeld && isSel && styles.sel, !isHeld && isLit && !isSel && styles.lit, @@ -57,7 +65,12 @@ function Key({ id, className, lit, held, selected, onPress }: KeyProps) { data-control={id} aria-label={panelLabel(id)} aria-pressed={isHeld || isSel} - onClick={onPress ? () => onPress(id) : undefined} + // Pointer events drive presses; this is the keyboard equivalent. + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + onKey(id); + }} > {isLit ? ( entries[0]!.short @@ -73,11 +86,76 @@ function Key({ id, className, lit, held, selected, onPress }: KeyProps) { ); } -export function Panel({ lit, held, selected, screen, onPress }: PanelProps) { - const key = { lit, held, selected: selected ?? null, onPress }; +export function Panel({ + lit, + held, + selected, + screen, + onPress, + onGestureEnd, +}: PanelProps) { + /** pointerId to the control it is holding. */ + const active = useRef(new Map()); + /** Everything touched since the first finger went down. */ + const gesture = useRef(new Set()); + const [down, setDown] = useState>(new Set()); + + // Kept in refs so the window listeners below can be attached once and still + // call the current callbacks. + const pressRef = useRef(onPress); + const endRef = useRef(onGestureEnd); + pressRef.current = onPress; + endRef.current = onGestureEnd; + + useEffect(() => { + // Fingers often lift outside the key they went down on, so releases are + // heard on the window rather than the button. + function release(e: PointerEvent) { + if (!active.current.has(e.pointerId)) return; + active.current.delete(e.pointerId); + setDown(new Set(active.current.values())); + if (active.current.size > 0) return; + + const ids = [...gesture.current]; + gesture.current.clear(); + if (ids.length > 0) endRef.current?.(ids); + } + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); + return () => { + window.removeEventListener("pointerup", release); + window.removeEventListener("pointercancel", release); + }; + }, []); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + const el = (e.target as HTMLElement).closest("[data-control]"); + if (!el) return; + const id = el.dataset["control"]; + if (!id) return; + + // Stops the browser treating a second finger as a pinch or a pan. + e.preventDefault(); + active.current.set(e.pointerId, id); + gesture.current.add(id); + setDown(new Set(active.current.values())); + pressRef.current?.(id); + }, []); + + /** Keyboard: press and end the gesture in one go. */ + const onKey = useCallback((id: ControlId) => { + pressRef.current?.(id); + endRef.current?.([id]); + }, []); + + const key = { lit, held, selected: selected ?? null, down, onKey }; return ( -
+
e.preventDefault()} + >
{KNOBS.map((id) => (