Timelinx

Snapping

How the snap index works, snap point priorities, and integrating snapping into custom tools

Snapping is the magnetic pull that makes clips jump to nearby boundaries when dragging close enough. The snap system is built on a sorted list of SnapPoint objects, each carrying a frame position, type, and priority. Tools call nearest() to find the closest snap target within a pixel radius.

SnapPoint

type SnapPoint = {
  readonly frame: TimelineFrame;
  readonly type: SnapPointType;
  readonly priority: number;
  readonly trackId: TrackId | null;  // null = timeline-wide (playhead, markers)
  readonly sourceId: string;         // clipId, markerId  - for exclusion
};

type SnapPointType =
  | 'ClipStart'
  | 'ClipEnd'
  | 'Playhead'
  | 'Marker'
  | 'InPoint'
  | 'OutPoint'
  | 'BeatGrid';

Priority table (higher wins when two points are equidistant):

TypePriorityWhen present
Marker100A marker exists on the timeline
InPoint90timeline.inPoint is set
OutPoint90timeline.outPoint is set
ClipStart80Start of any clip on any track
ClipEnd80End of any clip on any track
Playhead70Always present
BeatGrid50timeline.beatGrid is set

SnapIndex

type SnapIndex = {
  readonly points: readonly SnapPoint[];  // sorted ascending by frame
  readonly builtAt: number;               // Date.now()
  readonly enabled: boolean;
};

points is always sorted. nearest() can binary-search it efficiently even with thousands of clips.


buildSnapIndex(state, options?)

Constructs a snap index from the current TimelineState:

import { buildSnapIndex } from '@timelinx/core';

const index = buildSnapIndex(state, {
  includePlayhead: true,
  playheadFrame: currentFrame,
  includeBeatGrid: true,
});

TimelineEngine calls buildSnapIndex automatically after each accepted dispatch and stores the result in EngineSnapshot.snapIndex. In custom headless integrations, call it manually after each state change.


nearest(index, frame, radiusPx, ppf, excludeIds?)

Find the closest snap point to frame within a pixel radius:

import { nearest } from '@timelinx/core';

const snapPoint = nearest(
  index,
  candidateFrame,   // the frame the user is dragging toward
  8,                // snap radius in pixels
  ppf,              // pixels per frame (current zoom level)
  [movingClipId],   // exclude the clip being moved (so it doesn't snap to itself)
);

if (snapPoint !== null) {
  // Use snapPoint.frame instead of candidateFrame
}

The radius is specified in pixels - nearest() converts it to frames using radiusPx / ppf. At higher zoom levels, the snap radius covers fewer frames; at lower zoom, it covers more. This means snapping always feels the same distance to the user regardless of zoom.

Returns null if no snap point is within the radius.


toggleSnap(index)

import { toggleSnap } from '@timelinx/core';

const disabled = toggleSnap(index);  // { ...index, enabled: false }
const enabled  = toggleSnap(disabled); // { ...index, enabled: true }

When enabled is false, nearest() always returns null. Toggle with a button in the toolbar (common shortcut: S key).


SnapIndexManager

SnapIndexManager wraps buildSnapIndex with async rebuild scheduling to avoid blocking the main thread after large edits:

import { SnapIndexManager } from '@timelinx/core';

const manager = new SnapIndexManager();
manager.rebuildSync(state);      // synchronous, for initial build

manager.scheduleRebuild(state);  // async, after each dispatch
manager.current();               // returns the latest SnapIndex

TimelineEngine owns a SnapIndexManager internally. The snap index in EngineSnapshot.snapIndex is always current.


Using Snapping in a Custom Tool

Inside onPointerMove, apply snap before returning provisional state:

import { nearest } from '@timelinx/core';

class MyMoveTool implements ITool {
  private dragStartFrame: TimelineFrame | null = null;
  private originalClipStart: TimelineFrame | null = null;

  onPointerDown(event: TimelinePointerEvent, ctx: ToolContext) {
    this.dragStartFrame = event.frame;
    this.originalClipStart = ctx.state./* find clip */ ...;
  }

  onPointerMove(event: TimelinePointerEvent, ctx: ToolContext) {
    if (this.dragStartFrame === null) return null;

    const delta = event.frame - this.dragStartFrame;
    let candidateStart = (this.originalClipStart! + delta) as TimelineFrame;

    // Apply snap
    const snap = nearest(
      ctx.snapIndex,
      candidateStart,
      8,                    // 8px snap radius
      ctx.pixelsPerFrame,
      [event.clipId!],      // exclude the clip being dragged
    );
    if (snap !== null) {
      candidateStart = snap.frame;
    }

    return {
      clips: [{
        clipId: event.clipId!,
        trackId: event.trackId!,
        timelineStart: candidateStart,
        timelineEnd: (candidateStart + clipDuration) as TimelineFrame,
        isGhost: true,
      }],
    };
  }

  onPointerUp(event: TimelinePointerEvent, ctx: ToolContext) {
    // Commit the final position as a transaction
    // ...
  }
}

Always exclude the moving clip

Pass [clipId] to the excludeIds parameter so the clip doesn't snap to its own start and end frames while being dragged.

On this page