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;
}| Method | Returns | When called |
|---|---|---|
onPointerDown | void | Pointer pressed down. Store gesture start state here. |
onPointerMove | ProvisionalState | null | Pointer moved. Return provisional state for live drag preview; null for no preview. |
onPointerUp | Transaction | null | Pointer released. Return the transaction to commit; null to discard. |
onKeyDown | Transaction | null | Key pressed. Return a transaction or null. |
getCursor | string | null | Current CSS cursor. Called on pointer-move when the tool or provisional state changed. |
supportsCaptions | boolean | Return 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;
};| Field | Purpose |
|---|---|
state | The current committed state. Read tracks, clips, and assets from here. |
snapIndex | Built snap index. Pass to nearest() for magnetic snap. |
pixelsPerFrame | Current 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>
);
}