Interaction System
The interaction system is OpenGPEX's unified gesture dispatcher — a four-layer decoupled architecture that routes pointer events from raw DOM events to plugin-defined handlers with 60fps performance and zero gesture conflicts.
Four-Layer Architecture
stage/interaction/
├── Dispatcher.ts ← Layer 1: Event routing hub
├── Transaction.ts ← Layer 2: State read/write lifecycle
├── Math.ts ← Layer 3: Pure geometry calculations
└── handlers/ ← Layer 4: Business handler implementations
├── TransformHandler.ts
├── LayerMoveHandler.ts
└── ViewportPanHandler.ts
| Layer | Role | Imports |
|---|---|---|
| Dispatcher | Wraps DOM events into InteractionEvent, routes to matching handler |
Nothing |
| Transaction | Manages Fast Track read/write, undo snapshot lifecycle | Core state |
| Math | Pure functions for elastic bounds, pixel alignment, rect calculation | Nothing |
| Handlers | Declarative business logic for specific gestures | Transaction, Math |
Event Flow
DOM pointerdown
│
▼
Dispatcher.onPointerDown(e)
│
├── Wraps into InteractionEvent (with coordinates, modifiers, target)
│
├── Iterates registered handlers by priority (highest first)
│
├── Calls handler.test(e) → returns truthy if handler claims the event
│
└── First match wins → handler.onStart(e) is called
│
▼
pointer-move → handler.onMove(e) (60fps, via Fast Track)
│
▼
pointer-up → handler.onEnd(e) (commit or abort)
InteractionEvent
The dispatcher enriches raw pointer events with editor context:
interface InteractionEvent {
// Raw pointer data
screenX: number;
screenY: number;
worldX: number; // Transformed to world coordinates
worldY: number;
pressure: number; // For stylus support
// Modifier keys
shiftKey: boolean;
ctrlKey: boolean;
altKey: boolean;
// Editor context
activeFrame: Frame;
activeLayer: Layer | null;
camera: CameraState;
// DOM reference
nativeEvent: PointerEvent;
}
InteractionHandler Interface
Plugins register handlers via the interactions array in their plugin definition:
interface InteractionHandler {
id: string;
priority: number; // Higher = tested first (100 = overlays, 50 = tools, 0 = fallback)
test: (e: InteractionEvent) => unknown; // Return truthy to claim
onStart: (e: InteractionEvent) => void;
onMove: (e: InteractionEvent) => void;
onEnd: (e: InteractionEvent) => void;
onCancel?: (e: InteractionEvent) => void;
}
Priority Conventions
| Priority Range | Use Case |
|---|---|
| 100-200 | Overlay handles (crop box, resize corners) |
| 50-99 | Active tool interactions (brush, text) |
| 10-49 | Layer selection & move |
| 0-9 | Viewport pan (fallback) |
Declarative Transform Factory
For common transform operations (resize, move, rotate), use the createTransformHandler factory:
import { createTransformHandler } from './handlers/TransformHandler';
export const resizeHandler = createTransformHandler({
id: 'clip-box-resize',
priority: 100,
// 1. What triggers this handler?
test: (e) => {
const handle = e.nativeEvent.target?.closest('[data-handle]');
return handle?.dataset.handle ?? null;
},
// 2. What is the initial state?
getInitialState: (e) => currentRect,
// 3. What constraints apply?
getConstraints: (e) => ({
aspect: e.shiftKey ? 'lock' : 'free',
clamp: true, // Don't allow drag outside canvas
}),
// 4. Where to write the result? (called 60fps)
onUpdate: (e, newRect, tx) => {
tx.update({ cropBox: newRect }, 'frame');
},
});
This factory handles all the math (8-directional resize, aspect ratio locking, elastic bounds) internally.
Transaction Integration
Every handler receives a Transaction instance for safe state access:
onStart: (e) => {
const tx = new InteractionTransaction(e);
tx.begin(); // Marks interacting=true
this.tx = tx;
},
onMove: (e) => {
this.tx.update(layerId, {
x: e.worldX - this.offset.x,
y: e.worldY - this.offset.y,
});
},
onEnd: (e) => {
this.tx.commit(); // Fold → 1 undo step
},
Hit-Testing Pipeline
When no overlay handler claims an event, the dispatcher performs hit-testing:
- Iterate layers top-to-bottom (highest Z-index first)
- Check bounding box (fast AABB test)
- Check alpha channel (for precise pixel-level hit-testing on bitmaps)
- Return first hit → becomes the target for LayerMoveHandler
Built-in Handlers
| Handler | Priority | Trigger | Behavior |
|---|---|---|---|
| Crop Overlay | 100 | Click on crop handle | Resize crop box |
| Transform Overlay | 100 | Click on transform handle | Scale/rotate layer |
| Layer Move | 20 | Click on any layer | Drag to reposition |
| Viewport Pan | 0 | Space+drag or middle-click | Pan the camera |
| Viewport Zoom | 0 | Scroll wheel / pinch | Zoom the camera |
Next Steps
- Fast Track — How transactions write to volatile state
- Actions & Commands — How onEnd commits trigger state mutations
- Plugin System Overview — Registering interaction handlers in plugins
Last updated: 2026-06-14