/** * 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"; import { tapFeedback } from "./native"; import { CONTROL_GROUPS, FX_LEFT, FX_RIGHT, KNOBS, PAD_ROWS } from "./panelLayout"; import styles from "./Panel.module.css"; export interface ScreenText { readonly top: string; readonly right: string; readonly main: string; readonly chips: readonly string[]; } export interface PanelProps { readonly lit: ReadonlyMap; readonly held: readonly ControlId[]; readonly selected?: ControlId | null; readonly screen: ScreenText; /** A control went down. */ readonly onPress?: ((id: ControlId) => void) | undefined; /** That control's finger lifted. Explore's latch needs the timing per key. */ readonly onRelease?: ((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; readonly className: string | undefined; readonly lit: ReadonlyMap; readonly held: readonly ControlId[]; readonly selected: ControlId | null; 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, down, onKey }: KeyProps) { const entries = lit.get(id); const isHeld = held.includes(id); const isLit = entries !== undefined && entries.length > 0; const isSel = isLit && id === selected; const number = padNumber(id); return ( ); } export function Panel({ lit, held, selected, screen, onPress, onRelease, 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 releaseRef = useRef(onRelease); const endRef = useRef(onGestureEnd); pressRef.current = onPress; releaseRef.current = onRelease; 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) { const id = active.current.get(e.pointerId); if (id === undefined) return; active.current.delete(e.pointerId); setDown(new Set(active.current.values())); // Per-control, and before the gesture-wide callback: Explore's latch // decides hold-versus-tap from how long this one key was down. releaseRef.current?.(id); 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())); // Every key gets a tick, including the second and third finger of a chord. tapFeedback(); pressRef.current?.(id); }, []); /** Keyboard: a full press, release and gesture in one go, so taps latch. */ const onKey = useCallback((id: ControlId) => { pressRef.current?.(id); releaseRef.current?.(id); endRef.current?.([id]); }, []); const key = { lit, held, selected: selected ?? null, down, onKey }; return (
e.preventDefault()} >
{KNOBS.map((id) => ( ))}
{FX_LEFT.map((id) => ( ))}
{/* The circle sits behind the display and shows only as caps above and below it — no ring around the rectangle. */}
{FX_RIGHT.map((id) => ( ))}
{CONTROL_GROUPS.map((group) => (
{group.leftCaption ? {group.leftCaption} : null}
{group.left.map((id) => ( ))}
{group.rightCaption ? {group.rightCaption} : null}
{group.right.map((id) => ( ))} {group.rightKnob ? ( ) : null}
))}
{PAD_ROWS.map((row) => (
{row.pads.map((id) => ( ))}
))}
); }