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:

  1. Iterate layers top-to-bottom (highest Z-index first)
  2. Check bounding box (fast AABB test)
  3. Check alpha channel (for precise pixel-level hit-testing on bitmaps)
  4. 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


Last updated: 2026-06-14