Skip to content

Commit a5587d6

Browse files
committed
feat: enhance timeline functionality with drag overlay
1 parent 886a82f commit a5587d6

File tree

4 files changed

+266
-52
lines changed

4 files changed

+266
-52
lines changed

apps/web/src/components/editor/timeline/timeline-element.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -340,12 +340,12 @@ export function TimelineElement({
340340
? `Toggle mute ${selectedElements.length} elements`
341341
: `Toggle visibility ${selectedElements.length} elements`
342342
: hasAudio
343-
? isMuted
344-
? "Unmute"
345-
: "Mute"
346-
: element.hidden
347-
? "Show"
348-
: "Hide"}{" "}
343+
? isMuted
344+
? "Unmute"
345+
: "Mute"
346+
: element.hidden
347+
? "Show"
348+
: "Hide"}{" "}
349349
{!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
350350
</span>
351351
</ContextMenuItem>

apps/web/src/components/editor/timeline/timeline-track.tsx

Lines changed: 227 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export function TimelineTrackContent({
122122
};
123123

124124
const timelineRef = useRef<HTMLDivElement>(null);
125+
const trackContainerRef = useRef<HTMLDivElement>(null);
125126
const [isDropping, setIsDropping] = useState(false);
126127
const [dropPosition, setDropPosition] = useState<number | null>(null);
127128
const [wouldOverlap, setWouldOverlap] = useState(false);
@@ -132,6 +133,19 @@ export function TimelineTrackContent({
132133
} | null>(null);
133134

134135
const lastMouseXRef = useRef(0);
136+
const [dragOverlay, setDragOverlay] = useState<{
137+
show: boolean;
138+
time: number;
139+
duration: number;
140+
trackId: string | null;
141+
} | null>(null);
142+
143+
// Clear drag overlay when drag ends
144+
useEffect(() => {
145+
if (!dragState.isDragging) {
146+
setDragOverlay(null);
147+
}
148+
}, [dragState.isDragging]);
135149

136150
// Set up mouse event listeners for drag
137151
useEffect(() => {
@@ -229,16 +243,126 @@ export function TimelineTrackContent({
229243
}
230244

231245
updateDragTime(finalTime);
246+
247+
// Update drag overlay to show where element will be dropped
248+
// Show overlay on the track the mouse is currently over
249+
if (dragState.elementId && dragState.trackId) {
250+
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
251+
const element = sourceTrack?.elements.find(
252+
(e) => e.id === dragState.elementId
253+
);
254+
if (element) {
255+
const elementDuration =
256+
element.duration - element.trimStart - element.trimEnd;
257+
258+
// Check if mouse is over this track
259+
const trackContainer = trackContainerRef.current;
260+
const trackContainerRect = trackContainer?.getBoundingClientRect();
261+
262+
const isMouseOverThisTrack =
263+
trackContainerRect &&
264+
e.clientY >= trackContainerRect.top &&
265+
e.clientY <= trackContainerRect.bottom;
266+
267+
if (isMouseOverThisTrack) {
268+
// Only show overlay for tracks above the source track
269+
const sourceTrackIndex = tracks.findIndex(
270+
(t) => t.id === dragState.trackId
271+
);
272+
const currentTrackIndex = tracks.findIndex(
273+
(t) => t.id === track.id
274+
);
275+
276+
// Only show overlay if current track is above the source track
277+
if (currentTrackIndex < sourceTrackIndex) {
278+
// Calculate overlay time using same logic as drop handler
279+
let overlayTime = finalTime;
280+
const tracksContainer = tracksScrollRef.current;
281+
282+
if (tracksContainer) {
283+
const containerRect = tracksContainer.getBoundingClientRect();
284+
const scrollLeft = tracksContainer.scrollLeft;
285+
const mouseX = e.clientX - containerRect.left;
286+
const mouseTime = Math.max(
287+
0,
288+
(mouseX + scrollLeft) /
289+
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
290+
);
291+
const adjustedTime = Math.max(
292+
0,
293+
mouseTime - dragState.clickOffsetTime
294+
);
295+
const projectStore = useProjectStore.getState();
296+
const projectFps =
297+
projectStore.activeProject?.fps || DEFAULT_FPS;
298+
overlayTime = snapTimeToFrame(adjustedTime, projectFps);
299+
}
300+
301+
setDragOverlay({
302+
show: true,
303+
time: overlayTime,
304+
duration: elementDuration,
305+
trackId: track.id,
306+
});
307+
} else {
308+
// Clear overlay if not above source track
309+
setDragOverlay((prev) =>
310+
prev?.trackId === track.id ? null : prev
311+
);
312+
}
313+
} else {
314+
// Clear overlay if mouse left this track
315+
setDragOverlay((prev) =>
316+
prev?.trackId === track.id ? null : prev
317+
);
318+
}
319+
}
320+
}
232321
};
233322

234323
const handleMouseUp = (e: MouseEvent) => {
235-
if (!dragState.elementId || !dragState.trackId) return;
324+
if (!dragState.elementId || !dragState.trackId || !dragState.isDragging) {
325+
return;
326+
}
236327

237-
// If this track initiated the drag, we should handle the mouse up regardless of where it occurs
328+
// First, check if we should handle this mouseup at all for this track
238329
const isTrackThatStartedDrag = dragState.trackId === track.id;
239330

331+
// Get all necessary rects upfront
332+
const trackContainer = trackContainerRef.current;
333+
const trackContainerRect = trackContainer?.getBoundingClientRect();
240334
const timelineRect = timelineRef.current?.getBoundingClientRect();
241-
if (!timelineRect) {
335+
336+
// Check if mouse is over this track
337+
let isMouseOverThisTrack = false;
338+
339+
if (trackContainerRect) {
340+
const mouseX = e.clientX;
341+
const mouseY = e.clientY;
342+
343+
// Check if directly over the track
344+
const isDirectlyOver =
345+
mouseY >= trackContainerRect.top &&
346+
mouseY <= trackContainerRect.bottom &&
347+
mouseX >= trackContainerRect.left &&
348+
mouseX <= trackContainerRect.right;
349+
350+
if (isDirectlyOver) {
351+
isMouseOverThisTrack = true;
352+
}
353+
} else if (timelineRect) {
354+
// Fallback to timeline rect if track container not available
355+
isMouseOverThisTrack =
356+
e.clientY >= timelineRect.top &&
357+
e.clientY <= timelineRect.bottom &&
358+
e.clientX >= timelineRect.left &&
359+
e.clientX <= timelineRect.right;
360+
}
361+
362+
const tracksContainer = tracksScrollRef.current;
363+
364+
// If we don't have track container bounds, try to handle with source track only
365+
if (!trackContainerRect) {
242366
if (isTrackThatStartedDrag) {
243367
if (rippleEditingEnabled) {
244368
updateElementStartTimeWithRipple(
@@ -260,14 +384,44 @@ export function TimelineTrackContent({
260384
return;
261385
}
262386

263-
const isMouseOverThisTrack =
264-
e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
265-
266-
if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
387+
if (!isMouseOverThisTrack && !isTrackThatStartedDrag) {
388+
// Clear overlay if mouse left this track
389+
setDragOverlay((prev) => (prev?.trackId === track.id ? null : prev));
390+
return;
391+
}
267392

268-
const finalTime = dragState.currentTime;
393+
// Calculate final time - always use tracksScrollRef for consistent calculation
394+
// This works for both empty and non-empty tracks
395+
let finalTime = dragState.currentTime;
396+
if (tracksContainer) {
397+
const containerRect = tracksContainer.getBoundingClientRect();
398+
const scrollLeft = tracksContainer.scrollLeft;
399+
const mouseX = e.clientX - containerRect.left;
400+
const mouseTime = Math.max(
401+
0,
402+
(mouseX + scrollLeft) /
403+
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
404+
);
405+
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
406+
const projectStore = useProjectStore.getState();
407+
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
408+
finalTime = snapTimeToFrame(adjustedTime, projectFps);
409+
} else if (timelineRect) {
410+
// Fallback to timeline rect if scroll ref not available
411+
const mouseX = e.clientX - timelineRect.left;
412+
const mouseTime = Math.max(
413+
0,
414+
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
415+
);
416+
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
417+
const projectStore = useProjectStore.getState();
418+
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
419+
finalTime = snapTimeToFrame(adjustedTime, projectFps);
420+
}
269421

422+
// Handle drop on track
270423
if (isMouseOverThisTrack) {
424+
// PRIORITY 2: Handle drop ON track
271425
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
272426
const movingElement = sourceTrack?.elements.find(
273427
(c) => c.id === dragState.elementId
@@ -281,21 +435,28 @@ export function TimelineTrackContent({
281435
const movingElementEnd = finalTime + movingElementDuration;
282436

283437
const targetTrack = tracks.find((t) => t.id === track.id);
284-
const hasOverlap = targetTrack?.elements.some((existingElement) => {
285-
if (
286-
dragState.trackId === track.id &&
287-
existingElement.id === dragState.elementId
288-
) {
289-
return false;
290-
}
291-
const existingStart = existingElement.startTime;
292-
const existingEnd =
293-
existingElement.startTime +
294-
(existingElement.duration -
295-
existingElement.trimStart -
296-
existingElement.trimEnd);
297-
return finalTime < existingEnd && movingElementEnd > existingStart;
298-
});
438+
// For empty tracks, elements array is empty, so some() returns false (no overlap)
439+
// This is correct - empty tracks can always accept drops
440+
const hasOverlap = targetTrack
441+
? targetTrack.elements.some((existingElement) => {
442+
// Skip the element being moved if it's on the same track
443+
if (
444+
dragState.trackId === track.id &&
445+
existingElement.id === dragState.elementId
446+
) {
447+
return false;
448+
}
449+
const existingStart = existingElement.startTime;
450+
const existingEnd =
451+
existingElement.startTime +
452+
(existingElement.duration -
453+
existingElement.trimStart -
454+
existingElement.trimEnd);
455+
return (
456+
finalTime < existingEnd && movingElementEnd > existingStart
457+
);
458+
})
459+
: false; // If target track not found, allow drop (shouldn't happen)
299460

300461
if (!hasOverlap) {
301462
if (dragState.trackId === track.id) {
@@ -313,6 +474,7 @@ export function TimelineTrackContent({
313474
);
314475
}
315476
} else {
477+
// Moving to different track - handle the move
316478
moveElementToTrack(
317479
dragState.trackId,
318480
track.id,
@@ -333,17 +495,29 @@ export function TimelineTrackContent({
333495
);
334496
}
335497
});
498+
// End drag since we handled the drop on this track
499+
endDragAction();
500+
onSnapPointChange?.(null);
501+
return; // Don't let source track also handle this
336502
}
337503
}
338504
}
339505
} else if (isTrackThatStartedDrag) {
506+
// PRIORITY 3: Handle drop on source track
340507
// Mouse is not over this track, but this track started the drag
341-
// This means user released over ruler/outside - update position within same track
508+
// Check if element still exists in this track (might have been moved to another track)
342509
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
343510
const movingElement = sourceTrack?.elements.find(
344511
(c) => c.id === dragState.elementId
345512
);
346513

514+
// If element no longer exists in source track, it was moved - don't process
515+
if (!movingElement) {
516+
endDragAction();
517+
onSnapPointChange?.(null);
518+
return;
519+
}
520+
347521
if (movingElement) {
348522
const movingElementDuration =
349523
movingElement.duration -
@@ -383,6 +557,9 @@ export function TimelineTrackContent({
383557
// Clear snap point when drag ends
384558
onSnapPointChange?.(null);
385559
}
560+
561+
// Clear drag overlay
562+
setDragOverlay(null);
386563
};
387564

388565
document.addEventListener("mousemove", handleMouseMove);
@@ -930,8 +1107,8 @@ export function TimelineTrackContent({
9301107
const isCompatible = isVideoOrImage
9311108
? canElementGoOnTrack("media", track.type)
9321109
: isAudio
933-
? canElementGoOnTrack("media", track.type)
934-
: false;
1110+
? canElementGoOnTrack("media", track.type)
1111+
: false;
9351112

9361113
let targetTrack = tracks.find((t) => t.id === targetTrackId);
9371114

@@ -1106,6 +1283,7 @@ export function TimelineTrackContent({
11061283

11071284
return (
11081285
<div
1286+
ref={trackContainerRef}
11091287
className="w-full h-full hover:bg-muted/20"
11101288
onClick={(e) => {
11111289
// If clicking empty area (not on an element), deselect all elements
@@ -1122,6 +1300,29 @@ export function TimelineTrackContent({
11221300
ref={timelineRef}
11231301
className="h-full relative track-elements-container min-w-full"
11241302
>
1303+
{/* Drag Overlay - shows where element will be dropped */}
1304+
{dragOverlay?.show &&
1305+
dragOverlay.trackId === track.id &&
1306+
dragOverlay.time !== null && (
1307+
<div
1308+
className="absolute top-0 h-full pointer-events-none z-40"
1309+
style={{
1310+
left: `${
1311+
dragOverlay.time *
1312+
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
1313+
zoomLevel
1314+
}px`,
1315+
width: `${Math.max(
1316+
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
1317+
dragOverlay.duration *
1318+
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
1319+
zoomLevel
1320+
)}px`,
1321+
}}
1322+
>
1323+
<div className="h-full border-2 border-dashed border-primary/60 bg-primary/5 rounded-[0.5rem]" />
1324+
</div>
1325+
)}
11251326
{track.elements.length === 0 ? (
11261327
<div
11271328
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${

0 commit comments

Comments
 (0)