# Shared Input System v2 — Architecture ## Overview v2 is a 4-layer shared input pipeline that combines pointer gestures and keyboard events into one resolver/dispatcher stack. It lives in `packages/canvas/src/gestures/` and replaces the v1/v2 split where pointer logic lived in the gesture runtime while keyboard ownership was still scattered across separate hooks. ``` DOM Events (pointer, touch, wheel, keydown, keyup) | v Layer 1: NORMALIZE NormalizedPointer | + raw keyboard state v Layer 2: RECOGNIZE PointerGestureEvent | KeyInputEvent | (pointer gestures + direct key events) v Layer 3: RESOLVE ResolvedAction | (priority-sorted context stack + specificity scoring) v Layer 4: DISPATCH Side Effects (phase-aware handlers + provider-owned keyboard ownership) ``` ## File Map | File | Lines | Layer | Responsibility | |------|-------|-------|----------------| | `types.ts` | 179 | All | Type definitions (no runtime code) | | `normalize.ts` | 73 | 1 | PointerEvent -> NormalizedPointer, modifier extraction | | `timed-state.ts` | 159 | 2 | Pure state machine for tap counting + long-press | | `timed-state-runner.ts` | 88 | 2 | Timer bridge wrapping pure TimedState | | `specificity.ts` | 99 | 3 | Pattern-to-event scoring algorithm | | `mapper.ts` | 159 | 3 | Context stack resolution with bucketed lookup | | `contexts.ts` | 203 | 3 | Built-in contexts (palm rejection, default, input modes) | | `dispatcher.ts` | 106 | 4 | Action handler registry + phase routing | | `inertia.ts` | 138 | 4 | Pure momentum simulation (pan + zoom) | | `GestureProvider.tsx` | 52 | React | Context provider for shared gesture state | | `useGestureSystem.ts` | 81 | React | Consumer hook: context stack CRUD + mapping index | | `useCanvasGestures.ts` | 635 | React | Main viewport hook (drag, pinch, wheel, inertia) | | `useNodeGestures.ts` | 192 | React | Node-level hook (tap, double-tap, long-press, right-click) | | `useInputModeGestureContext.ts` | 49 | React | Auto-push/pop contexts when input mode changes | | `index.ts` | 71 | - | Barrel export | ## Layer 1: Normalize **Files:** `normalize.ts` **Pure functions, no DOM listeners.** ```typescript extractModifiers(e) -> Modifiers { shift, ctrl, alt, meta, custom? } normalizePointer(e: PointerEvent) -> NormalizedPointer ``` `classifyPointer` (from `core/input-classifier.ts`) determines `InputSource`: `'finger' | 'pencil' | 'mouse'` based on `pointerType` and pressure heuristics. `Modifiers.custom` remains available for non-keyboard flags only. Held keyboard state is explicit in `heldKeys.byKey` / `heldKeys.byCode`. ## Layer 2: Recognize **Files:** `timed-state.ts`, `timed-state-runner.ts` ### TimedState Machine (pure) Per-pointer state machine that tracks tap sequences and long-press timing. Keyboard does not go through this state machine; it enters the runtime as direct `KeyInputEvent`s. Returns `TimedStateResult` with: - `state`: next state - `emit?`: gesture type to emit (`'tap'`, `'double-tap'`, `'triple-tap'`, `'long-press'`) - `scheduleTimer?` / `cancelTimer?`: timer commands for the caller ``` States: idle -> pressed -> { long-pressed | released } -> idle down: idle -> pressed (schedule long-press timer) released -> pressed (increment tapCount) up: pressed -> released (emit tap/double-tap/triple-tap) move: pressed -> idle (cancel — becomes drag via @use-gesture) timer: pressed -> long-pressed (emit long-press) settle: released -> idle (finalize tap sequence) cancel: any -> idle (cleanup) ``` **Key design:** The state machine is pure — no `setTimeout` inside. The `TimedStateRunner` class manages actual timers and calls `transition()` on timer fire. ### @use-gesture Integration `@use-gesture/react` handles drag, pinch, and wheel recognition. The gesture hooks configure it with source-aware thresholds: | Source | Drag threshold | Tap threshold | |--------|---------------|---------------| | finger | 10px | 10px | | pencil | 2px | 3px | | mouse | 3px | 5px | ## Layer 3: Resolve **Files:** `specificity.ts`, `mapper.ts`, `contexts.ts` ### Specificity Scoring Scores how well an `InputPattern` matches an `InputEvent`. Higher = more specific. Returns `-1` on mismatch. | Dimension | Score | Why | |-----------|-------|-----| | `type` | 128 | Must outweigh all others combined | | `key` | 64 | Specific key binding | | `code` | 64 | Physical key binding | | `subjectKind` | 32 | Narrows to element category | | `modifiers` (positive, e.g. `shift: true`) | 16 each | Requires key pressed | | `modifiers` (negative, e.g. `shift: false`) | 8 each | Requires key NOT pressed | | `heldKeys` (positive) | 16 each | Held-key keyboard context | | `heldKeys` (negative) | 8 each | Explicit absence of held key | | `source` | 4 | Input device | | `button` | 2 | Mouse button | Keyboard matching is not encoded as `GestureType`. It matches `kind: 'key'` plus `phase`, `key`, and/or `code`. Max possible: 128 + 32 + N*16 + 4 + 2 = 166 + N*16 ### MappingIndex Contexts are indexed by pointer gesture type or keyboard phase into buckets for O(1) lookup: ```typescript type MappingIndex = ContextIndex[] // sorted by priority ascending (0 = highest) interface ContextIndex { contextId: string priority: number enabled: boolean buckets: Map } ``` Pointer buckets use `pointer:${type}`. Keyboard buckets use `key:${phase}`. ### Resolution Algorithm ``` resolve(event, index, guardContext): for each context (priority ascending = highest first): skip if !enabled lookup bucket for event.type (+ wildcard bucket) for each binding in bucket: score = specificity(pattern, event) skip if -1 (no match) skip if binding.when(guard) returns false track best match (highest score) if best match found: if consumeInput → return immediately (block lower contexts) else → record as candidate return highest-priority candidate or null ``` ### Built-in Contexts **Palm Rejection** (priority 0 — highest): - When stylus is active, blocks all finger taps/long-press via `when: ctx => ctx.isStylusActive` - Remaps finger drag-on-node to pan - Pinch + scroll intentionally NOT blocked **Default** (priority 100 — lowest): - Taps: select node/edge, clear selection, shift-toggle - Drags: move-node, pan (per source), lasso/rect-select - Held-key pointer modifiers: `Space+drag` - Right-click: context-menu - Double/triple-tap: fit-to-view, toggle-lock - Long-press: context-menu on node, create-node on background - Pinch: zoom on background, split-node on node - Scroll: zoom - Keyboard shortcuts: search, clipboard, history, delete, escape **Shared keyboard contexts**: - active-interaction cancellation - search navigation - keyboard navigate mode - keyboard manipulate mode **Input Mode Contexts** (priority 5): - `PICK_NODE_CONTEXT`: tap-node -> resolve-pick-node - `PICK_NODES_CONTEXT`: tap-node -> resolve-pick-node, double-tap-bg -> finish - `PICK_POINT_CONTEXT`: tap-anywhere -> resolve-pick-point ### GuardContext Runtime state available to `when` predicates on bindings: ```typescript interface GuardContext { isStylusActive: boolean fingerCount: number isDragging: boolean isResizing: boolean isSplitting: boolean inputMode: InputMode keyboardInteractionMode: 'navigate' | 'manipulate' selectedNodeIds: ReadonlySet focusedNodeId: string | null isSearchActive: boolean commandLineVisible: boolean heldKeys: HeldKeysState custom: Record // extensible } ``` Rebuilt from Jotai atoms each render (cheap — just reads). ## Layer 4: Dispatch **Files:** `dispatcher.ts`, `inertia.ts` ### Action Handlers Two forms: ```typescript // Simple pointer handler — fires on pointer 'start' and 'instant' registerAction('select-node', (event: PointerGestureEvent) => { ... }) // Keyboard handler — fires on key 'down' registerAction('delete-selection', (event: KeyInputEvent) => { ... }) // Phase-aware — different logic per phase (for continuous gestures) registerAction('pan', { onStart: (e) => { ... }, onMove: (e) => { ... }, onEnd: (e) => { startInertia(e.velocity) }, onCancel: () => { cancelInertia() }, }) ``` Handler registry is a plain `Map` — not Jotai atoms. Synchronous, immediate invocation. `'none'` actionId is a built-in no-op (used for intentional blocking in palm rejection). ### Inertia Pure engines with no `requestAnimationFrame` dependency — the hook manages the rAF loop. - `VelocitySampler`: ring buffer of recent velocity samples - `PanInertia`: friction-decayed momentum (`friction = 0.92`) - `ZoomInertia`: friction-decayed zoom with snap-to-100% (`friction = 0.88`, `snapThreshold = 0.03`) ## React Integration ### Component Hierarchy ``` Canvas.tsx └─ GestureProvider (holds GestureSystemAPI + onAction callback) ├─ Viewport.tsx │ └─ useCanvasGestures(ref, mappingIndex, onAction) │ ├─ @use-gesture for drag/pinch/wheel │ ├─ TimedStateRunner for background taps │ ├─ Inertia engines for momentum │ └─ resolve() + dispatch() on every gesture event │ └─ Node.tsx (for each node) └─ useNodeGestures(nodeId, mappingIndex, onAction) ├─ TimedStateRunner for node taps └─ resolve() + dispatch() for tap/double-tap/long-press/right-click ``` ### MappingIndex Flow ``` useGestureSystem() builds: [palmRejection?] + [staticContexts] + [dynamicContexts] + DEFAULT_CONTEXT useMemo → buildMappingIndex() → MappingIndex ↓ GestureProvider holds in React context ↓ Viewport reads via useGestureContext() → passes to useCanvasGestures Node reads via useGestureContext() → passes to useNodeGestures ``` Both hooks call `resolve(event, mappingIndex, guardContext)` with the same index — consistent resolution across viewport and node-level gestures. ### Consumer API ```typescript const gestureSystem = useGestureSystem({ palmRejection: true }) // Push a custom context at runtime gestureSystem.pushContext({ id: 'drawing-mode', priority: 50, enabled: true, bindings: [ { id: 'pencil-draw', pattern: { type: 'drag', source: 'pencil' }, actionId: 'draw', consumeInput: true }, ], }) // Remove it gestureSystem.removeContext('drawing-mode') ``` ## Pointer Ownership - Each active pointer is owned by at most one gesture stream - When `@use-gesture` recognizes a pinch (2+ pointers), single-pointer drags are cancelled - Node drag uses `setPointerCapture` with `capture: false` on viewport to prevent event stealing - Local gesture scopes (resize handles, edge creation, split) maintain their own pointer maps and suppress pipeline gestures via GuardContext flags ## Relationship to v1 Two action systems coexist: | | v1 (`core/action-registry.ts`) | v2 (`gestures/dispatcher.ts`) | |-|------|------| | **Handler type** | `(ActionContext, ActionHelpers) => void` | `(GestureEvent) => void` or PhaseHandler | | **Registration** | `ActionDefinition` objects with metadata | Simple `registerAction(id, handler)` | | **Execution** | `executeAction(id, context, helpers)` | `dispatch(event, resolution)` | | **Scope** | Commands, keyboard shortcuts, menu items | Pointer/touch gesture pipeline | v1 actions (`BuiltInActionId`) have rich metadata (label, description, category, icon) and receive `ActionHelpers` for state manipulation. v2 handlers are lightweight — just the gesture event. Both registries are global Maps. They can reference the same action IDs — a gesture resolution can trigger a v1 action by ID. ## Test Coverage | Test | Module | What | |------|--------|------| | `specificity.test.ts` | Scoring | Pattern matching, weight ordering, mismatch rejection | | `timed-state.test.ts` | State machine | Transitions, tap counting, long-press, cancel | | `mapper.test.ts` | Resolution | Bucketing, priority, consumeInput, guard evaluation | | `inertia.test.ts` | Momentum | Friction decay, velocity sampling, snap-to-zoom | | `palm-rejection.test.ts` | Palm rejection | Stylus blocking, pinch passthrough | All tests are pure unit tests (no DOM, no React) — run via `pnpm exec vitest run` from `packages/canvas/`. ## Key Design Principles 1. **Pure core, imperative shell**: normalize, timed-state, specificity, mapper, inertia are all pure functions. DOM listeners, timers, and rAF live in hooks. 2. **Refs for hot path**: Drag position, velocity samples, inertia engines use `useRef` — no re-renders during 60fps gesture streams. 3. **Specificity over priority**: Within a context, the most specific match wins automatically. Between contexts, explicit priority ordering resolves conflicts. 4. **consumeInput for hard blocks**: Palm rejection doesn't "disable" finger input — it matches finger gestures at priority 0 with `consumeInput: true`, preventing default context from seeing them. 5. **Context stack is data**: MappingContexts are plain objects. Push/pop at runtime for modal states (pick mode, edge creation, drawing mode) without touching handler code. 6. **Custom modifiers for extensibility**: `Modifiers.custom` bag supports arbitrary named modifiers (iPad toolbar buttons, held keys) with full specificity scoring — no special-casing needed.