Phase 2: multitouch
Panel used onClick, which cannot express a chord at all - so hold mode, which is the persisted default answering mode, was unusable. It now tracks pointers per pointerId and reports onPress per control plus onGestureEnd when the last finger lifts. The split is deliberate: Panel owns pointer bookkeeping because it owns the DOM, and App keeps deciding what a chord means. Hold mode grades on onGestureEnd, tap mode accumulates presses and waits for Check, and Explore still selects on press. Both map onto reducer actions that already existed. Two details carried over from the reference that are easy to delete by accident, and are now written down: - touch-action: none on the panel. Without it the browser claims the second finger as a pinch or a pan and no chord can ever form. - pointerup is heard on the window, not the button, because fingers routinely lift outside the key they went down on. Keyboard still works: Enter or Space presses and ends the gesture. 141 tests, typecheck clean, touch-action confirmed in the built CSS, and the panel renders unchanged. Actual multi-finger behaviour cannot be verified headlessly - that needs the phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
21
CLAUDE.md
21
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
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ---- */
|
||||
|
||||
100
src/ui/Panel.tsx
100
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<ControlId, Entry[]>;
|
||||
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<ControlId, Entry[]>;
|
||||
readonly held: readonly ControlId[];
|
||||
readonly selected: ControlId | null;
|
||||
readonly onPress?: ((id: ControlId) => void) | undefined;
|
||||
readonly down: ReadonlySet<ControlId>;
|
||||
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<number, ControlId>());
|
||||
/** Everything touched since the first finger went down. */
|
||||
const gesture = useRef(new Set<ControlId>());
|
||||
const [down, setDown] = useState<ReadonlySet<ControlId>>(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<HTMLDivElement>) => {
|
||||
const el = (e.target as HTMLElement).closest<HTMLElement>("[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 (
|
||||
<div className={styles.panel}>
|
||||
<div
|
||||
className={styles.panel}
|
||||
onPointerDown={onPointerDown}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={styles.knobs}>
|
||||
{KNOBS.map((id) => (
|
||||
<Key key={id} id={id} className={styles.knob} {...key} />
|
||||
|
||||
Reference in New Issue
Block a user