feat: visual feedback for invalid drag-and-drop targets#1392
Conversation
- 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>
There was a problem hiding this comment.
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
DragGhostInfowithisValidDropand compute validity during drag. - Render invalid-state styling for drag ghost (red tint + ✕ badge) and adjust lane highlight intensity for valid targets.
- Add
DragTooltipwith localStorage-based “show first N times” behavior and wire it intoClipBlock.
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.
| const onGhostLanding = useCallback(() => { | ||
| setGhostLanding(true); | ||
| setShowDragTooltip(false); | ||
| setTimeout(() => { | ||
| setDragGhost(null); | ||
| setGhostLanding(false); | ||
| }, 200); | ||
| }, []); |
There was a problem hiding this comment.
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.
| <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 | ||
| </> | ||
| )} |
There was a problem hiding this comment.
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.
| 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). */ |
There was a problem hiding this comment.
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.
| /** 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). */ |
| // 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; |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
Summary
isValidDropflag toDragGhostInfodetecting group tracks and unsupported multi-select cross-track movesDragTooltipcomponent showing "Drag: move | Shift: copy" for first 3 drags (localStorage tracked)Changes
src/components/timeline/useClipDrag.ts—isValidDropfield + detection logicsrc/components/timeline/ClipDragGhost.tsx— Valid/invalid visual statessrc/components/timeline/DragTooltip.tsx— New tooltip component + persistence helperssrc/components/timeline/ClipBlock.tsx— Wire tooltip display on first dragTest plan
npx tsc --noEmit— 0 type errorsnpm test— 3872 tests passCloses #1361
🤖 Generated with Claude Code