13 KiB
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.tsas the global pointer/source truthcore/interaction-store.tsas the source of input modescore/selection-path-store.tsfor lasso / rect-select state@use-gesture/reactfor drag / pinch / wheel recognition- local gesture scopes where pointer capture or special ownership is required
Replace
core/gesture-rules.tscore/gesture-rule-store.tscore/settings-types.tscore/settings-store.tscore/action-registry.tscore/action-executor.tshooks/useCanvasSettings.tscomponents/SettingsPanel.tsx- gesture logic embedded in
Viewport.tsx,Node.tsx, anduseNodeDrag.ts
Delete At Cutoff
gestureRulesprop- 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
- Replace the current split gesture logic with one shared-input runtime.
- Fix lifecycle bugs around
pointercancel, hidden tabs, stale stylus state, and orphaned timers. - Move from event-name mapping to input-event mapping with context layering and phase-aware actions.
- Keep local scopes only where they are structurally necessary.
- 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
CanvasEventTypebridge
Core Runtime Types
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.tsnormalize.tstimed-state.tsspecificity.tsmapper.tscontexts.tsdispatcher.tsinertia.tsuseCanvasGestures.tsuseNodeGestures.tsuseGestureSystem.tsuseInputModeGestureContext.ts
Extend or keep:
core/input-store.tscore/interaction-store.tscore/selection-path-store.tshooks/useNodeResize.tshooks/useSplitGesture.ts
Delete after cutoff:
core/gesture-rules.tscore/gesture-rule-store.tscore/settings-types.tscore/settings-store.tscore/action-registry.tscore/action-executor.tshooks/useCanvasSettings.tscomponents/SettingsPanel.tsx
Public API Direction
Replace the old callback-heavy surface with a gesture-native API.
Remove
gestureRulesonGestureActiononNodeClickonNodeDoubleClickonNodeTripleClickonNodeRightClickonNodeLongPressonBackgroundClickonBackgroundDoubleClickonBackgroundRightClickonBackgroundLongPress
Add
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
Viewportpan / pinch / wheel / background gesture behavior - tests for
Nodetap, double-tap, triple-tap, right-click, and long-press behavior - tests for
useNodeDragmodifier 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
pointercancelin 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.tsuseNodeGestures.tsuseGestureSystem.tsuseInputModeGestureContext.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.tsxusesuseCanvasGestures.tsNode.tsxusesuseNodeGestures.tsuseNodeDrag.tsis 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:
pnpm --filter @blinksgg/canvas vitest runpnpm --filter @blinksgg/canvas check-typespnpm --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, anduseNodeDrag.tsare 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.