Timelinx

Tool System

The ITool interface, built-in editing tools, the tool registry, and how to build custom tools

The tool system maps pointer and keyboard events to timeline operations. Every editing gesture - drag to move a clip, click to select, trim an edge - flows through a tool. Tools read from snap context, produce provisional state during gestures, and commit transactions on release.

The engine owns the active tool. UI components forward events to the engine; the engine dispatches to the tool; the tool returns provisional state or a transaction.

The ITool Interface

interface ITool {
  readonly id: ToolId;
  readonly cursor: string | null;

  onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void;
  onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null;
  onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null;
  onKeyDown?(event: TimelineKeyEvent, ctx: ToolContext): Transaction | null;
  getCursor?(ctx: ToolContext): string | null;
  supportsCaptions?(): boolean;
}
MethodReturnsWhen called
onPointerDownvoidPointer pressed down. Store gesture start state here.
onPointerMoveProvisionalState | nullPointer moved. Return provisional state for live drag preview; null for no preview.
onPointerUpTransaction | nullPointer released. Return the transaction to commit; null to discard.
onKeyDownTransaction | nullKey pressed. Return a transaction or null.
getCursorstring | nullCurrent CSS cursor. Called on pointer-move when the tool or provisional state changed.
supportsCaptionsbooleanReturn true if this tool handles caption gestures. Default is false.

ToolContext

Every tool handler receives a ToolContext:

type ToolContext = {
  readonly state: TimelineState;
  readonly snapIndex: SnapIndex;
  readonly pixelsPerFrame: number;
};
FieldPurpose
stateThe current committed state. Read tracks, clips, and assets from here.
snapIndexBuilt snap index. Pass to nearest() for magnetic snap.
pixelsPerFrameCurrent zoom level. Use to convert pixel deltas to frame deltas.

The TimelineEngine rebuilds and injects a fresh ToolContext on every pointer event.


TimelinePointerEvent

The engine translates raw DOM pointer events to a semantic TimelinePointerEvent before passing them to tools:

type TimelinePointerEvent = {
  readonly x: number;             // client X in pixels
  readonly y: number;             // client Y in pixels
  readonly frame: TimelineFrame;  // resolved frame at x (scroll + x / ppf)
  readonly trackId?: TrackId;     // track under pointer (from data-track-id)
  readonly clipId?: ClipId;       // clip under pointer (from data-clip-id)
  readonly captionId?: CaptionId; // caption under pointer
  readonly edge?: 'start' | 'end'; // if near a trim handle
};

frame, trackId, and clipId are resolved by the tool router from the DOM. Tools receive the resolved context - they do not read from the DOM directly.


ProvisionalState

During a drag, a tool returns ProvisionalState from onPointerMove. This state is stored in the engine but never dispatched - it is for preview only.

type ProvisionalState = {
  readonly clips: readonly ProvisionalClip[];
  readonly rubberBand?: RubberBandRegion;
};

type ProvisionalClip = {
  readonly clipId: ClipId;
  readonly trackId: TrackId;
  readonly timelineStart: TimelineFrame;
  readonly timelineEnd: TimelineFrame;
  readonly isGhost?: boolean;  // true = translucent preview
};

UI components read provisional state via useProvisional() and render ghost clips or rubber-band selection overlays on top of the committed clip layer.


Tool Registry

The registry holds all available tools and tracks the active one.

import { createRegistry, activateTool, getActiveTool, registerTool } from '@timelinx/core';

// Create a registry with default tools
const registry = createRegistry(tools, toToolId('selection'));

// Switch the active tool
const nextRegistry = activateTool(registry, toToolId('razor'));

// Get the active tool instance
const activeTool = getActiveTool(registry);

// Add a custom tool at runtime
const withCustom = registerTool(registry, new MyCustomTool());

activateTool and registerTool return a new registry - the input is not mutated.

The TimelineEngine manages the registry internally. In React apps you call engine.activateTool(toolId) rather than manipulating the registry directly.


Built-in Tools

TimelineEngine registers 12 tools by default. All can be activated by string ID.

SelectionTool - 'selection'

The default tool. Handles the most complex gesture set.

Click on empty space: clears selection. Click on a clip: selects it (shift-click for multi-select). Drag on a clip: moves it. Provisional state shows the clip at its new position. Drag near a trim handle (within snap radius): trims that edge instead. Drag on empty space: draws a rubber-band region and selects all clips inside.

Commits: MOVE_CLIP, RESIZE_CLIP, or no-op (selection change only - selection is engine state, not dispatch state).


RazorTool - 'razor'

Click on a clip: slices it at the pointer frame. Commits SLICE_CLIP.

The cursor changes to a razor icon over clips. Snaps to the snap index when within snap radius.


RippleTrimTool - 'ripple-trim'

Drag near a clip edge: trims that edge and ripple-shifts all subsequent clips on the same track to close or expand the gap.

Commits a transaction with RESIZE_CLIP for the trimmed clip and MOVE_CLIP for each following clip.


RollTrimTool - 'roll-trim'

Drag the shared edge between two adjacent clips: extends one and shrinks the other by the same amount. The total timeline duration of the two clips stays constant.

Commits: RESIZE_CLIP for both the outgoing and incoming clip in one transaction.


SlipTool - 'slip'

Drag horizontally on a clip: keeps the timeline position fixed and moves the media window (mediaIn / mediaOut) - changing which frames of the source play without changing the clip's timeline position.

Commits: SET_MEDIA_BOUNDS.


SlideTool - 'slide'

Drag a clip: moves the clip and ripple-adjusts the neighbors to keep the total edit duration constant. Neighboring clips are trimmed to fill or accept the space.

Commits: MOVE_CLIP + RESIZE_CLIP on neighbors.


RippleDeleteTool - 'ripple-delete'

Click on a clip: deletes the clip and shifts all subsequent clips left to close the gap.

Commits: DELETE_CLIP + MOVE_CLIP for each following clip.


RippleInsertTool - 'ripple-insert'

Drag onto a track: pushes all clips at or after the pointer frame right by the drag width, then inserts a new clip.

Commits: MOVE_CLIP for each affected clip + INSERT_CLIP.


HandTool - 'hand'

Drag: pans the timeline view. Does not commit any timeline operations - scroll position is UI state, not document state.


TransitionTool - 'transition'

Click near a clip edge (within transition zone): adds a dissolve transition centered on the cut point. Drag on an existing transition handle: adjusts its duration.

Commits: ADD_TRANSITION or SET_TRANSITION_DURATION.


KeyframeTool - 'keyframe'

Click on an effect parameter graph lane: adds a keyframe at the pointer frame. Drag an existing keyframe: moves it.

Commits: ADD_KEYFRAME or MOVE_KEYFRAME.


ZoomTool - 'zoom'

Click: zooms in one step centered on the pointer frame. Alt + click: zooms out. Drag: defines a zoom region to fill the viewport.

Zoom changes are UI state only. The tool calls options.onZoomChange(ppf) from TimelineEngineOptions rather than committing a transaction.


Registering Custom Tools

Pass additional tools via TimelineEngineOptions.tools. They are merged with the defaults - the same tool ID overwrites the built-in.

import { TimelineEngine } from '@timelinx/react';
import type { ITool, ToolContext, TimelinePointerEvent } from '@timelinx/core';
import { toToolId } from '@timelinx/core';

class AnnotationTool implements ITool {
  readonly id = toToolId('annotation');
  readonly cursor = 'crosshair';

  private startFrame: TimelineFrame | null = null;

  onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void {
    this.startFrame = event.frame;
  }

  onPointerMove(event: TimelinePointerEvent, ctx: ToolContext) {
    // No provisional state for this tool
    return null;
  }

  onPointerUp(event: TimelinePointerEvent, ctx: ToolContext) {
    if (this.startFrame === null) return null;
    const start = this.startFrame;
    this.startFrame = null;

    // Commit a range marker
    return {
      id: `tx-annotate-${Date.now()}`,
      label: 'Add annotation',
      timestamp: Date.now(),
      operations: [
        {
          type: 'ADD_MARKER',
          marker: {
            type: 'range',
            id: toMarkerId(`marker-${Date.now()}`),
            frameStart: start,
            frameEnd: event.frame,
            label: 'Annotation',
            color: '#f1c40f',
            scope: 'personal',
            linkedClipId: null,
          },
        },
      ],
    };
  }
}

const engine = new TimelineEngine({
  initialState,
  tools: [new AnnotationTool()],
});

Activate it the same way as any built-in:

engine.activateTool('annotation');

Activating Tools from React

import { useEngine } from '@timelinx/react';

function Toolbar() {
  const engine = useEngine();

  return (
    <div>
      <button onClick={() => engine.activateTool('selection')}>Select</button>
      <button onClick={() => engine.activateTool('razor')}>Razor</button>
      <button onClick={() => engine.activateTool('ripple-trim')}>Ripple Trim</button>
    </div>
  );
}

useActiveToolId() returns the current tool ID for highlighting the active button:

import { useActiveToolId, useEngine } from '@timelinx/react';

function ToolButton({ toolId, label }: { toolId: string; label: string }) {
  const engine = useEngine();
  const activeId = useActiveToolId();

  return (
    <button
      aria-pressed={activeId === toolId}
      onClick={() => engine.activateTool(toolId)}
    >
      {label}
    </button>
  );
}

On this page