Skip to content

feat: visual feedback for invalid drag-and-drop targets#1392

Merged
ChuxiJ merged 2 commits intomainfrom
feat/issue-1361-drag-feedback
Apr 2, 2026
Merged

feat: visual feedback for invalid drag-and-drop targets#1392
ChuxiJ merged 2 commits intomainfrom
feat/issue-1361-drag-feedback

Conversation

@ChuxiJ
Copy link
Copy Markdown

@ChuxiJ ChuxiJ commented Apr 2, 2026

Summary

  • Add isValidDrop flag to DragGhostInfo detecting group tracks and unsupported multi-select cross-track moves
  • Invalid targets: red-tinted ghost, red ✕ badge, dimmed opacity, red lane highlight
  • Valid targets: enhanced lane highlight (brighter than before)
  • Add DragTooltip component showing "Drag: move | Shift: copy" for first 3 drags (localStorage tracked)
  • Shift+copy badge (green +) suppressed on invalid targets

Changes

  • src/components/timeline/useClipDrag.tsisValidDrop field + detection logic
  • src/components/timeline/ClipDragGhost.tsx — Valid/invalid visual states
  • src/components/timeline/DragTooltip.tsx — New tooltip component + persistence helpers
  • src/components/timeline/ClipBlock.tsx — Wire tooltip display on first drag
  • 7 unit tests for tooltip helpers and ghost validity interface

Test plan

  • npx tsc --noEmit — 0 type errors
  • npm test — 3872 tests pass
  • Manual: drag clip to group track — red ghost + ✕ badge
  • Manual: drag clip to valid track — green/blue highlight
  • Manual: first 3 drags show tooltip, subsequent don't

Closes #1361

🤖 Generated with Claude Code

ChuxiJ and others added 2 commits April 2, 2026 15:22
- Add isValidDrop flag to DragGhostInfo, detecting group tracks and
  unsupported multi-select cross-track moves
- Invalid targets show red-tinted ghost with x badge and not-allowed cursor
- Valid cross-track targets show enhanced lane highlight (brighter)
- Add DragTooltip showing Drag: move | Shift: copy for first 3 drags
- 7 unit tests for tooltip helpers and ghost validity

Closes #1361

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 2, 2026 07:25
@ChuxiJ ChuxiJ merged commit ca2631a into main Apr 2, 2026
4 checks passed
@ChuxiJ ChuxiJ deleted the feat/issue-1361-drag-feedback branch April 2, 2026 07:25
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds UX feedback for timeline clip drag-and-drop by introducing an isValidDrop flag on the drag ghost, rendering distinct valid/invalid visual states (ghost + lane highlight), and showing a short-lived drag-tooltip for new users (tracked via localStorage).

Changes:

  • Extend DragGhostInfo with isValidDrop and compute validity during drag.
  • Render invalid-state styling for drag ghost (red tint + ✕ badge) and adjust lane highlight intensity for valid targets.
  • Add DragTooltip with localStorage-based “show first N times” behavior and wire it into ClipBlock.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/components/timeline/useClipDrag.ts Computes isValidDrop during dragging and includes it in ghost info.
src/components/timeline/ClipDragGhost.tsx Applies valid/invalid styling (ghost, badges, lane highlight).
src/components/timeline/DragTooltip.tsx New tooltip component + localStorage helper functions.
src/components/timeline/ClipBlock.tsx Displays tooltip on initial drags and hides it during landing.
src/components/timeline/tests/DragTooltip.test.tsx Unit tests for tooltip localStorage helpers.
src/components/timeline/tests/DragGhostValidity.test.ts Type/behavior tests for DragGhostInfo.isValidDrop.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 75 to 82
const onGhostLanding = useCallback(() => {
setGhostLanding(true);
setShowDragTooltip(false);
setTimeout(() => {
setDragGhost(null);
setGhostLanding(false);
}, 200);
}, []);
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

onGhostLanding clears dragGhost via setDragGhost(null) instead of going through handleDragGhostChange(null), so dragTooltipCounted.current never gets reset on a successful drop. After the first completed drag, the tooltip will stop appearing even if shouldShowDragTooltip() is still true. Reset dragTooltipCounted.current when the landing animation completes (or call handleDragGhostChange(null) in the timeout) so each new drag can be counted/shown correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +56
<div className="bg-zinc-900/95 border border-zinc-700 rounded px-2.5 py-1.5 text-[10px] text-zinc-300 whitespace-nowrap shadow-lg backdrop-blur-sm">
<span className="text-zinc-400">Drag:</span> move
<span className="mx-1.5 text-zinc-600">|</span>
<span className="text-zinc-400">Shift:</span> copy
{isMac ? null : (
<>
<span className="mx-1.5 text-zinc-600">|</span>
<span className="text-zinc-400">Alt:</span> slip
</>
)}
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The tooltip copy doesn’t match the PR description / issue requirements: the PR description says the tooltip shows only “Drag: move | Shift: copy”, and the linked issue specifies “Ctrl+Drag: slip”, but the component renders “Alt: slip” (and hides it on macOS). Align the displayed modifiers with the actual gesture mapping (useClipDrag uses altKey for slip) and/or update the PR description/requirements accordingly so users aren’t taught the wrong shortcut.

Copilot uses AI. Check for mistakes.
targetLaneRect: { top: number; height: number } | null;
sourceLaneRect: { top: number; height: number } | null;
isShiftCopy?: boolean;
/** Whether the current drop target is valid (false when hovering outside lanes or on group tracks). */
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The JSDoc for isValidDrop says it becomes false “when hovering outside lanes”, but the current drag code computes validity only based on group-track and multi-select cross-track checks (and findClosestLaneCached always returns the nearest lane when any lanes exist). Either update the comment to reflect the actual semantics, or extend the validity calculation to cover the “outside lanes” case if that’s intended behavior.

Suggested change
/** Whether the current drop target is valid (false when hovering outside lanes or on group tracks). */
/** Whether the current drop target is valid (e.g. false on group tracks or for invalid cross-track moves). */

Copilot uses AI. Check for mistakes.
Comment on lines +307 to +312
// Determine drop validity
const isEmptySlot = closest.trackId.startsWith(ARRANGEMENT_EMPTY_TRACK_ID_PREFIX);
const targetTrack = isEmptySlot ? null : useProjectStore.getState().project?.tracks.find((t) => t.id === closest.trackId);
const isGroupTarget = targetTrack?.isGroup === true;
const isMultiCrossTrack = isMultiSelected && crossingTrack && !isShiftCopy;
const isValidDrop = !isGroupTarget && !isMultiCrossTrack;
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

isValidDrop is computed for visual feedback, but it is not used to gate the actual drop behavior on mouseup (e.g., group-track targets will still reach the cross-track move path, and multi-select cross-track drops will still be committed as a move). Consider reusing the same validity logic during onMouseUp to prevent committing invalid drops and to keep behavior consistent with the new UI feedback.

Copilot uses AI. Check for mistakes.
Comment on lines +308 to +310
const isEmptySlot = closest.trackId.startsWith(ARRANGEMENT_EMPTY_TRACK_ID_PREFIX);
const targetTrack = isEmptySlot ? null : useProjectStore.getState().project?.tracks.find((t) => t.id === closest.trackId);
const isGroupTarget = targetTrack?.isGroup === true;
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

This introduces a per-mousemove linear scan over project.tracks to find the hovered track (tracks.find(...)). During drag, this runs at high frequency and could become noticeable in large projects. Consider precomputing a Set/Map of group track IDs at drag start (or deriving it from already-available state) so validity checks are O(1) per frame.

Copilot uses AI. Check for mistakes.
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.

feat: Visual feedback for invalid drag-and-drop targets

2 participants