canvas/docs/plan-gesture-system-v2.md

503 lines
13 KiB
Markdown
Raw Normal View History

2026-03-11 18:42:08 -07:00
# Gesture System v2 Plan
## Recommendation
Do a hard cutoff.
Do not spend time on adapters, dual dispatch, legacy callbacks, or persisted settings compatibility. v2 should replace the current gesture surface with one coherent shared-input runtime and delete the old stack in the same migration.
No deprecation window. No compatibility bridge. One breaking migration.
This is still not a blind greenfield rewrite. Reuse low-level pieces that are already correct, but stop designing around compatibility.
---
## Cutoff Decision
### Keep
- `core/input-store.ts` as the global pointer/source truth
- `core/interaction-store.ts` as the source of input modes
- `core/selection-path-store.ts` for lasso / rect-select state
- `@use-gesture/react` for drag / pinch / wheel recognition
- local gesture scopes where pointer capture or special ownership is required
### Replace
- `core/gesture-rules.ts`
- `core/gesture-rule-store.ts`
- `core/settings-types.ts`
- `core/settings-store.ts`
- `core/action-registry.ts`
- `core/action-executor.ts`
- `hooks/useCanvasSettings.ts`
- `components/SettingsPanel.tsx`
- gesture logic embedded in `Viewport.tsx`, `Node.tsx`, and `useNodeDrag.ts`
### Delete At Cutoff
- `gestureRules` prop
- per-event compatibility callbacks such as `onNodeClick`, `onNodeDoubleClick`, `onBackgroundClick`, etc.
- `CanvasEventType`
- presets and event-to-action settings persistence
- any legacy adapter or bridge layer
---
## Goals
1. Replace the current split gesture logic with one shared-input runtime.
2. Fix lifecycle bugs around `pointercancel`, hidden tabs, stale stylus state, and orphaned timers.
3. Move from event-name mapping to input-event mapping with context layering and phase-aware actions.
4. Keep local scopes only where they are structurally necessary.
5. End with less code, fewer parallel abstractions, and one public gesture API.
## Non-Goals
- Maintaining old props, old settings, or old storage formats
- Preserving old click / double-click semantics exactly
- Rebuilding the settings UI in the same phase as the runtime cutoff
- Unifying resize, minimap, edge creation, and split into the central runtime on day one
---
## Current Problems
| Area | Problem |
|------|---------|
| `Viewport.tsx` | Pan, zoom, inertia, long-press, threshold logic, and gesture ownership are all inline |
| `Node.tsx` | Rendering is mixed with click counting, touch long-press timers, and context menu handling |
| `useNodeDrag.ts` | Drag mechanics are coupled to gesture policy and right-drag suppression |
| `gesture-rules` + `settings-types` + `action-registry` | The system is split between gesture matching, event mappings, and action execution with overlapping concerns |
| settings UI | The current UI is built around discrete event names, not gesture streams |
The main mistake to avoid is preserving this split under a new name.
---
## Architecture
v2 should have a single shared-input pipeline with four layers.
### Layer 1: Normalize
Normalize raw DOM input into pointer events plus shared keyboard state:
- source
- button
- modifiers
- screen position
- pressure
- timestamp
- pointer lifecycle including `cancel`
- coalesced points when available
### Layer 2: Recognize
Turn normalized pointers into pointer gestures and model keyboard as a parallel input event stream:
- drag / pinch / wheel via `@use-gesture/react`
- tap / double-tap / triple-tap / long-press via a small timed state machine
- explicit gesture ownership rules
- source-aware thresholds in one shared module
### Layer 3: Resolve
Resolve recognized pointer gestures and keyboard events against one context stack:
- default bindings
- palm rejection context
- input-mode context
- local blocking contexts
- consumer-pushed contexts
### Layer 4: Dispatch
Dispatch all input events through one action model:
- discrete gestures use `phase: 'instant'`
- continuous gestures use `start` / `move` / `end` / `cancel`
- no separate `CanvasEventType` bridge
---
## Core Runtime Types
```ts
type GestureSubject =
| { kind: 'background' }
| { kind: 'node'; nodeId: string }
| { kind: 'edge'; edgeId: string }
| { kind: 'port'; nodeId: string; portId: string };
type GestureType =
| 'tap'
| 'double-tap'
| 'triple-tap'
| 'long-press'
| 'drag'
| 'pinch'
| 'scroll';
type GesturePhase = 'start' | 'move' | 'end' | 'instant' | 'cancel';
interface GestureEvent {
type: GestureType;
phase: GesturePhase;
subject: GestureSubject;
source: 'mouse' | 'finger' | 'pencil';
button: 0 | 1 | 2;
modifiers: { shift: boolean; ctrl: boolean; alt: boolean; meta: boolean };
pointerId?: number;
screenPosition: { x: number; y: number };
worldPosition: { x: number; y: number };
delta?: { x: number; y: number };
velocity?: { x: number; y: number };
scale?: number;
originalEvent?: Event;
}
interface Binding {
id: string;
pattern: Partial<Pick<GestureEvent, 'type' | 'source' | 'button' | 'phase'>> & {
subjectKind?: GestureSubject['kind'];
modifiers?: Partial<GestureEvent['modifiers']>;
};
actionId: string;
consumeInput?: boolean;
when?: (ctx: GuardContext) => boolean;
}
interface MappingContext {
id: string;
priority: number;
enabled: boolean;
bindings: Binding[];
}
```
This replaces the old `CanvasEventType -> actionId` model completely.
---
## Design Decisions
### 1. Replace Event-Mapping With Gesture-Mapping
The current settings/event system is built around labels like `node:click` and `background:long-press`.
That model is too narrow for:
- continuous phases
- pointer ownership
- source-aware gesture behavior
- context stacking
v2 should map actual gesture patterns to actions, not synthetic event names.
### 2. Own Click And Tap Recognition In One Place
Do not keep browser `click`, `dblclick`, and `contextmenu` as a separate path.
v2 should own:
- tap
- double-tap / double-click
- triple-tap
- long-press
- right-click
That gives one recognition model across mouse, touch, and pencil.
### 3. Keep Local Scopes Where The Physics Differ
These stay local in v2:
| Interaction | Why |
|-------------|-----|
| Resize handles | direct geometry updates + pointer capture |
| Minimap drag | separate coordinate system |
| Edge creation drag | direct hit testing + pointer capture |
| Split gesture | specialized two-finger ownership |
These local scopes still interact with the runtime by:
- setting guard flags
- pushing temporary blocking contexts
- dispatching high-level actions when needed
### 4. Input Mode Must Use The Real Store Shape
`interaction-store.ts` already models input mode as a discriminated union. Keep that.
Guard context should carry:
- the full input mode object, or
- a derived `inputMode.type`
Do not flatten it into an invented string union.
### 5. Context Sync Belongs In Hooks
Input mode and local blocking contexts should be pushed from explicit hooks.
Do not hide that work in effectful derived atoms.
### 6. No Legacy Surface At The End
When v2 lands:
- the old public props are removed
- the old settings UI is removed or disabled
- the old stores and registry files are deleted
No compatibility release.
---
## New File Plan
Create a dedicated `packages/canvas/src/gestures/` module:
- `types.ts`
- `normalize.ts`
- `timed-state.ts`
- `specificity.ts`
- `mapper.ts`
- `contexts.ts`
- `dispatcher.ts`
- `inertia.ts`
- `useCanvasGestures.ts`
- `useNodeGestures.ts`
- `useGestureSystem.ts`
- `useInputModeGestureContext.ts`
Extend or keep:
- `core/input-store.ts`
- `core/interaction-store.ts`
- `core/selection-path-store.ts`
- `hooks/useNodeResize.ts`
- `hooks/useSplitGesture.ts`
Delete after cutoff:
- `core/gesture-rules.ts`
- `core/gesture-rule-store.ts`
- `core/settings-types.ts`
- `core/settings-store.ts`
- `core/action-registry.ts`
- `core/action-executor.ts`
- `hooks/useCanvasSettings.ts`
- `components/SettingsPanel.tsx`
---
## Public API Direction
Replace the old callback-heavy surface with a gesture-native API.
### Remove
- `gestureRules`
- `onGestureAction`
- `onNodeClick`
- `onNodeDoubleClick`
- `onNodeTripleClick`
- `onNodeRightClick`
- `onNodeLongPress`
- `onBackgroundClick`
- `onBackgroundDoubleClick`
- `onBackgroundRightClick`
- `onBackgroundLongPress`
### Add
```ts
interface CanvasProps {
gestureConfig?: {
contexts?: MappingContext[];
debug?: boolean;
palmRejection?: boolean;
};
onAction?: (event: GestureEvent, resolution: ResolvedAction) => void;
}
```
If consumers need click-specific behavior, they express it as bindings and action handlers, not bespoke props.
---
## Phased Implementation Plan
## Phase 0: Capture Expected Behavior
Before rewriting, document the behavior we still care about.
Deliverables:
- tests for `Viewport` pan / pinch / wheel / background gesture behavior
- tests for `Node` tap, double-tap, triple-tap, right-click, and long-press behavior
- tests for `useNodeDrag` modifier and button behavior
- tests for stylus palm rejection
- tests for split-vs-pinch ownership on nodes
Success criteria:
- we have a concrete acceptance matrix
- we know what to preserve functionally and what can change
## Phase 1: Lifecycle Hardening
Fix the bugs that should survive the rewrite.
Deliverables:
- handle `pointercancel` in all pointer-tracking code
- clear active pointers on `visibilitychange`
- cancel timers and inertia on hidden tab, cancel, and unmount
- extract pure source-threshold and inertia helpers
- add cancellation handling to `useSplitGesture.ts`
Success criteria:
- no stale pointer state
- no orphaned timers
- no runaway inertia
## Phase 2: Build The New Pure Runtime
Implement the new system in `gestures/` without touching public components yet.
Deliverables:
- normalization layer
- timed state machine
- specificity scoring
- context stack resolver
- phase-aware dispatcher
- inertia engine
Success criteria:
- all pure pieces are independently testable
- the runtime has no dependency on old event-mapping types
## Phase 3: Add React Integration
Wire the runtime into hooks and stores.
Deliverables:
- `useCanvasGestures.ts`
- `useNodeGestures.ts`
- `useGestureSystem.ts`
- `useInputModeGestureContext.ts`
- hook-driven context sync for input modes and local blockers
Success criteria:
- hooks can drive gestures end-to-end without using the old rule or settings stack
## Phase 4: Cut Off Components
Replace the old component gesture paths directly.
Deliverables:
- `Viewport.tsx` uses `useCanvasGestures.ts`
- `Node.tsx` uses `useNodeGestures.ts`
- `useNodeDrag.ts` is reduced to drag mechanics
- DOM click / dblclick / contextmenu paths are removed
- old callback props are removed from `Canvas.tsx`
Success criteria:
- one runtime owns gesture interpretation
- components mostly render and forward gesture props
## Phase 5: Delete The Old Stack
Remove everything that only existed for the old model.
Deliverables:
- delete old rules and settings files
- delete the settings panel
- delete the old action registry / executor
- delete unused exports
- update docs and examples to the new API
Success criteria:
- no dead compatibility code remains
- there is one obvious path for gesture behavior
---
## Cutover Risks
### 1. Desktop Semantics Will Change
Owning double-click and context menu behavior ourselves means web behavior may differ from native browser expectations.
That is acceptable, but it must be deliberate.
### 2. Settings UI Disappears Until Rebuilt
If the current settings panel is still valuable, it should be rebuilt later against gesture bindings and contexts. It should not block the runtime rewrite.
### 3. `@use-gesture` Ownership Must Be Verified
Do not assume pinch cancellation and drag ownership work exactly as the new runtime wants.
Test:
- pinch over background
- split over node
- drag interrupted by second pointer
- cancel during active gesture
### 4. Local Scopes Can Drift
Resize, minimap, edge creation, and split are intentionally local at first. If they do not publish guard state consistently, gesture conflicts will remain.
---
## Verification
Automated:
1. `pnpm --filter @blinksgg/canvas vitest run`
2. `pnpm --filter @blinksgg/canvas check-types`
3. `pnpm --filter @blinksgg/canvas build`
Manual:
- mouse: tap, double-tap, right-click, drag, wheel zoom
- touch: pan, pinch zoom, long-press
- pencil: tap, drag, lasso
- stylus + palm rejection
- node drag with modifiers and alternate buttons
- split gesture on node vs pinch on background
- resize handles
- minimap drag
- edge creation
- input modes overriding normal behavior
- visibility change during active gesture
Exit criteria:
- all supported gestures route through the new runtime
- old settings and callback code is deleted
- no stale pointer or timer bugs remain
- `Viewport.tsx`, `Node.tsx`, and `useNodeDrag.ts` are materially smaller
---
## Summary
The right v2 is a hard cutoff, not a compatibility bridge.
Build one gesture-native runtime, cut the old event-mapping stack, keep only the low-level stores that still make sense, and delete the rest in the same migration.