Skip to content

fix: dragging a multi-selection only moved the grabbed clip - #10

Open
GinoongFlores wants to merge 1 commit into
Ekaanth:mainfrom
GinoongFlores:fix/timeline-group-drag
Open

fix: dragging a multi-selection only moved the grabbed clip#10
GinoongFlores wants to merge 1 commit into
Ekaanth:mainfrom
GinoongFlores:fix/timeline-group-drag

Conversation

@GinoongFlores

Copy link
Copy Markdown

Selecting several clips and dragging one only moved that one clip; the rest stayed put.

Root cause: the drag hook only ever tracked a single element (dragState.elementId/trackId), regardless of how many clips were selected.

  • add MoveElementsCommand: batch-moves several elements as one undo step, each staying on its own track
  • use-element-interaction: snapshot every selected clip's startTime at drag start when the grabbed clip is part of a multi-selection, shift them all by the same delta on drop, block the move if it would overlap a non-selected clip
  • new-track drops still fall back to single-element move (out of scope)

- add MoveElementsCommand: batch-moves several elements as one undo step,
  each staying on its own track (no cross-track drop for group drags)
- use-element-interaction: snapshot every selected clip's startTime at
  drag start when the grabbed clip is part of a multi-selection, shift
  them all by the same delta on drop, block the move if it would
  overlap a non-selected clip
- new-track drops still fall back to single-element move (out of scope)

@vorflux vorflux Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Reviewed — found 4 issue(s). This PR adds group-drag support for multi-selected clips via a new MoveElementsCommand and logic in use-element-interaction.ts. The approach is sound — it reuses the same snapshot-undo pattern as the existing single-element command. However, the new command is missing enforceMainTrackStart, which can leave gaps at the start of the main video track, and per-element Math.max(0, …) clamping can cause undetected intra-group overlaps.

Findings

apps/web/src/lib/commands/timeline/element/move-elements.ts

  1. Missing enforceMainTrackStart call in execute(). The existing MoveElementCommand.execute() calls enforceMainTrackStart to ensure the main video track always starts at time 0. MoveElementsCommand.execute() never does. If a selected element on the main track is dragged left far enough, it can land at a non-zero time, leaving a gap at the track start and violating the invariant. See inline comment for fix suggestion.

  2. Silently skips missing elements. When a trackId:elementId pair isn't found in the current tracks, the command silently continues — unlike MoveElementCommand which throws. This makes debugging harder if stale element IDs slip in. See inline comment.

  3. No unit tests. The new MoveElementsCommand and group-drag logic in use-element-interaction.ts have zero test coverage. The project already uses Bun for testing (keyframe-aware-commands.test.ts as reference); tests should cover execute/undo/redo, empty updates, and main-track pinning.

apps/web/src/hooks/timeline/element/use-element-interaction.ts

  1. Math.max(0, …) clamping can cause undetected intra-group overlaps. Each element's new start is clamped individually to max(0, start + delta). If the delta is negative enough to clamp one element on a track to 0 but not another, their relative positions shift and they can overlap — but both are excluded from the collision check. See inline comment.

Verdict

⚠️ Changes requested. Fix issue 1 (main track enforcement) before landing — it's a data-integrity concern on the primary video track. Issue 4 (clamping overlap) is an edge case that can ship with a follow-up PR. Add unit tests for MoveElementsCommand as a blocking precondition.


Review with Vorflux

Comment on lines +183 to +207
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();

const newStartTimeByKey = new Map(
this.updates.map((update) => [
`${update.trackId}:${update.elementId}`,
update.newStartTime,
]),
);

const updatedTracks = this.savedState.map((track): TimelineTrack => {
if (!("elements" in track)) return track;
let changed = false;
const elements = track.elements.map((element) => {
const newStartTime = newStartTimeByKey.get(`${track.id}:${element.id}`);
if (newStartTime === undefined) return element;
changed = true;
return { ...element, startTime: newStartTime };
});
return changed ? ({ ...track, elements } as typeof track) : track;
});

editor.timeline.updateTracks(updatedTracks);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing enforceMainTrackStart — main track invariant can be violated.

The existing MoveElementCommand.execute() (above this class) calls enforceMainTrackStart to ensure the main video track always starts at time 0. MoveElementsCommand.execute() never calls it.

If a selected element lives on the main track and the group is dragged left far enough, that element can land at a non-zero time while still being the earliest element on the track — leaving a gap at the track start.

Suggested fix: For each update targeting the main track, call enforceMainTrackStart after computing the updated tracks. Note that enforceMainTrackStart takes a single excludeElementId, so you may need to iteratively enforce or extend it to accept a set of excluded IDs.

Comment on lines +194 to +204
const updatedTracks = this.savedState.map((track): TimelineTrack => {
if (!("elements" in track)) return track;
let changed = false;
const elements = track.elements.map((element) => {
const newStartTime = newStartTimeByKey.get(`${track.id}:${element.id}`);
if (newStartTime === undefined) return element;
changed = true;
return { ...element, startTime: newStartTime };
});
return changed ? ({ ...track, elements } as typeof track) : track;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silently skips missing elements — no error or warning.

When a trackId:elementId key isn't found in the current tracks (newStartTime === undefined), the command silently continues without marking changed = true. The existing MoveElementCommand throws "Source track or element not found" in the same situation.

In practice this shouldn't happen during normal drag, but it means stale element IDs from a bug elsewhere would produce silent no-ops — no error, toast, or warning. Consider logging a warning (at minimum) or throwing to match the existing command's behavior.

Comment on lines +465 to +469
const updates = groupDrag.map((g) => ({
trackId: g.trackId,
elementId: g.elementId,
newStartTime: Math.max(0, g.startTime + delta),
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-element Math.max(0, …) clamping can cause undetected intra-group overlaps.

Each element's new start is clamped individually (Math.max(0, g.startTime + delta)), but the collision check (hasCollision) filters out all group members via movingElementIds. If the delta is negative enough to clamp one element on a track to 0 but not another on the same track, their relative positions shift and they can overlap — undetected.

Example: track has selected elements A at time=2, B at time=12. Delta=-5. A → max(0, -3) = 0, B → max(0, 7) = 7. If A.duration > 7, they overlap.

Suggested fix: After computing updates, re-check pairwise overlaps among moved elements that share the same track. Or clamp the entire delta uniformly so no element goes negative: clampedDelta = max(0 - minStartTime, delta).

Comment on lines +609 to +623
groupDragRef.current = selectedElements
.map((selected) => {
const selectedTrack = tracks.find((t) => t.id === selected.trackId);
const selectedElement = selectedTrack?.elements.find(
(el) => el.id === selected.elementId,
);
return selectedElement
? {
trackId: selected.trackId,
elementId: selected.elementId,
startTime: selectedElement.startTime,
}
: null;
})
.filter((entry) => entry !== null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

groupDragRef snapshot silently filters out deleted elements.

At mousedown, groupDragRef.current maps selectedElements through tracks.find() and filters out null entries. If elements from the selection no longer exist in tracks (stale selection from a prior bug), the ref becomes empty ([]) and the group-drag branch is skipped — only the grabbed clip moves, not the multi-selection.

This is a hard-to-hit edge case, but a defensive console.warn when entries are filtered out would aid debugging.

@vorflux

vorflux Bot commented Aug 27, 2026

Copy link
Copy Markdown

Risk Assessment

The PR adds group-drag support with a well-structured approach, but the new MoveElementsCommand skips main-track enforcement and the per-element clamping introduces an edge case for intra-group overlaps — both are data-integrity concerns on a limited blast radius.

Risk score: 4/10 (threshold: 3) — above the configured risk threshold
Overall risk: Medium

Concerns

  1. Missing enforceMainTrackStart in MoveElementsCommand. Can leave a gap at the start of the main video track when a multi-selected main-track element is dragged left. The blast radius is limited to projects with multi-selected elements on the main track dragged far enough left to trigger the condition.

  2. Per-element Math.max(0, …) clamping can cause undetected intra-group overlaps. When the delta is negative enough to clamp one element to 0 but not another on the same track, they can overlap without triggering the collision check. Narrow edge case but represents a data-integrity concern.

  3. No test coverage. Both the new command and the group-drag integration logic lack unit tests, reducing confidence. The existing codebase has Bun-based test infrastructure ready for use.

Verdict

⚠️ Ship with mitigations. Fix the main-track enforcement issue and add unit tests for MoveElementsCommand before landing. The clamping overlap edge case can be addressed in a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant