Contents
toy-midi · PR #370 explain-diff

One drag,
many clips.

Recorder selection now spans imported audio and recorded takes. A drag captures one group snapshot, clamps one shared delta, and commits both clip types through one runtime mutation.

2 clip typesAudio tracks and recorded takes share selection.
4 px thresholdClick intent is separated from drag intent.
1 shared deltaRelative timing survives boundary clamping.

The behavior contract

Pointer action
Target selected?
Modifier
Result
Click
Either
None
Select only target
Click
Either
Ctrl/Cmd
Toggle target
Drag
Yes
Either
Preserve selected group
Drag
No
Ctrl/Cmd
Add target, move group
Drag
No
None
Replace selection, move target

Invariant: group movement changes every selected clip by the same clamped timeline delta. The runtime never reorders takes, so newer-take-wins comping remains intact.

Timeline boundary at 0
Imported audio
Recorded take
same Δ →

First classify intent

Pointer down

onStart captures Ctrl/Cmd state and gesture-local data immediately.

→

Under 4 px

Pointer up becomes onClick. Selection changes without moving anything.

or

Cross 4 px

onDragStart resolves group membership and snapshots positions before live movement.

Causal code walkthrough

1

Pointer routing stays generic

listenPointerDrag owns pointer capture, identity, end, and cancellation. The new gesture utility layers click-versus-drag classification on top rather than duplicating routing.

src/utils/pointer-gesture.ts:48-80
if (!state.dragged && deltaSquared >= threshold ** 2) {
  state.dragged = true;
  onDragStart(event, gesture);
}
if (state.dragged) onDragMove(event, gesture);
2

The clip binds intent to recorder semantics

onStart captures modifiers. Only confirmed drags call startMove, while sub-threshold releases call selection.

src/components/recorder/index.tsx:1925-1953
onStart: event => ({
  additive: event.ctrlKey || event.metaKey,
  snapshot: undefined,
}),
onClick: (_, { data }) => onClipClick?.(data.additive)
3

One private selection model spans clip types

Audio tracks and takes map into private string keys. Callers only see semantic operations such as isSelected, select, and startMove.

src/components/recorder/index.tsx:510-556
function select(clip, additive) {
  const key = getKey(clip);
  if (!additive) return setKeys(new Set([key]));
  const next = new Set(keys);
  next.has(key) ? next.delete(key) : next.add(key);
  setKeys(next);
}
4

Drag start freezes the movement facts

The selected IDs, each original timeline offset, and the group’s minimum visible start are captured once. Trimming matters because the visible boundary is timelineOffset + trimStart.

src/components/recorder/index.tsx:558-596
return {
  clips,
  minimumVisibleStart: Math.min(
    ...audioTracks.map(t => t.timelineOffset + t.trimStart),
    ...takes.map(t => t.timelineOffset + t.trimStart),
  ),
};
5

Every move uses one clamp

The earliest visible clip determines how far left the group can travel. Applying the same result to every original offset preserves spacing.

src/components/recorder/index.tsx:598-607
const clampedDelta = Math.max(
  delta,
  -snapshot.minimumVisibleStart,
);
runtime.moveClips(snapshot.clips.map(clip => ({
  ...clip,
  timelineOffset: clip.timelineOffset + clampedDelta,
})));
6

The runtime commits mixed offsets atomically

The runtime validates all IDs, pauses once if needed, derives both next track states, performs one store update, synchronizes playback and comp regions, then resumes once.

src/lib/recorder/runtime.ts:231-288
const audioTracks = state.audioTracks.map(updateAudioOffset);
const recordingTrack = {
  ...state.recordingTrack,
  takes: state.recordingTrack.takes.map(updateTakeOffset),
};
this.store.update({ audioTracks, recordingTrack });

Behavior is tested end to end

Selection

Import audio, record a take, plain-click audio, then Ctrl-click the take. Both expose data-selected=true.

Shared movement

Dragging the take by roughly 80 px moves both bounding boxes by the same amount.

Clear selection

Clicking empty timeline space removes selection from both clip types.

Regression suite

pnpm lint, 134 unit tests, and all 3 focused recorder E2E scenarios pass.

Read the pinned E2E scenario

Check your model

1. Why is selection not changed in onStart?

2. What preserves spacing when the group reaches timeline zero?

3. Why does the snapshot use timelineOffset + trimStart?

4. Why does moveClips update audio and takes together?

5. What happens when Ctrl/Cmd-drag begins on an unselected clip?

Generated from toy-midi PR #370 at commit 57cee20e. The artifact explains the final diff against main, not the intermediate commit sequence.