diff --git a/app/components/media/Transitions.tsx b/app/components/media/Transitions.tsx index f91108d..2bfd57f 100644 --- a/app/components/media/Transitions.tsx +++ b/app/components/media/Transitions.tsx @@ -53,7 +53,7 @@ const TransitionThumbnail = ({ transition, isSelected, onClick }: { type: "transition", presentation: transition.type, timing: "linear", - durationInFrames: 4 * FPS, + durationInFrames: 1 * FPS, leftScrubberId: null, rightScrubberId: null, }; diff --git a/app/components/timeline/Scrubber.tsx b/app/components/timeline/Scrubber.tsx index 067fd7e..3082204 100644 --- a/app/components/timeline/Scrubber.tsx +++ b/app/components/timeline/Scrubber.tsx @@ -21,8 +21,6 @@ export interface ScrubberProps { pixelsPerSecond: number; isSelected?: boolean; onSelect?: (scrubberId: string) => void; - // Add transitions prop to check for overlapping allowance - transitions?: Transition[]; } export const Scrubber: React.FC = ({ @@ -38,7 +36,6 @@ export const Scrubber: React.FC = ({ pixelsPerSecond, isSelected = false, onSelect, - transitions = [], }) => { const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); @@ -98,61 +95,7 @@ export const Scrubber: React.FC = ({ [snapConfig, getSnapPoints] ); - // Check if there's a transition between two scrubbers that allows overlap - const hasTransitionBetween = useCallback( - (scrubber1Id: string, scrubber2Id: string) => { - return transitions.some( - (transition) => - (transition.leftScrubberId === scrubber1Id && transition.rightScrubberId === scrubber2Id) || - (transition.leftScrubberId === scrubber2Id && transition.rightScrubberId === scrubber1Id) - ); - }, - [transitions] - ); - - // Check collision with track awareness - allow overlap if transition exists - const checkCollisionWithTrack = useCallback( - (newScrubber: ScrubberState, excludeId?: string) => { - return otherScrubbers.some((other) => { - if (other.id === excludeId || other.y !== newScrubber.y) return false; - - const otherStart = other.left; - const otherEnd = other.left + other.width; - const newStart = newScrubber.left; - const newEnd = newScrubber.left + newScrubber.width; - - const hasOverlap = !(newEnd <= otherStart || newStart >= otherEnd); - - // If there's overlap, check if there's a transition that allows it - if (hasOverlap && hasTransitionBetween(newScrubber.id, other.id)) { - return false; // Allow overlap due to transition - } - return hasOverlap; - }); - }, - [otherScrubbers, hasTransitionBetween] - ); - - const getScrubberBounds = useCallback( - (scrubber: ScrubberState) => { - const scrollLeft = containerRef.current?.scrollLeft || 0; - return { - left: scrubber.left + scrollLeft, - right: scrubber.left + scrubber.width + scrollLeft, - }; - }, - [containerRef] - ); - - const checkCollision = useCallback( - (scrubber1: ScrubberState, scrubber2: ScrubberState) => { - const bounds1 = getScrubberBounds(scrubber1); - const bounds2 = getScrubberBounds(scrubber2); - return !(bounds1.right <= bounds2.left || bounds1.left >= bounds2.right); - }, - [getScrubberBounds] - ); const handleMouseDown = useCallback( (e: React.MouseEvent, mode: "drag" | "resize-left" | "resize-right") => { @@ -186,7 +129,6 @@ export const Scrubber: React.FC = ({ const handleMouseMove = useCallback( (e: MouseEvent) => { if (!isDragging && !isResizing) return; - // Remove throttling and requestAnimationFrame for responsive dragging if (isDragging) { let rawNewLeft = e.clientX - dragStateRef.current.offsetX; @@ -204,72 +146,12 @@ export const Scrubber: React.FC = ({ newTrack = Math.max(0, Math.min(trackCount - 1, trackIndex)); } - // Smart collision handling with push-through logic - const rawScrubber = { ...scrubber, left: rawNewLeft, y: newTrack }; - - // Find colliding scrubbers on the same track - const collidingScrubbers = otherScrubbers.filter(other => { - if (other.y !== newTrack) return false; - const otherStart = other.left; - const otherEnd = other.left + other.width; - const newStart = rawNewLeft; - const newEnd = rawNewLeft + scrubber.width; - return !(newEnd <= otherStart || newStart >= otherEnd); - }); - - if (collidingScrubbers.length === 0) { - // No collision - try snapping to nearby edges - const snappedLeft = findSnapPoint(rawNewLeft, scrubber.id); - const snappedScrubber = { ...scrubber, left: snappedLeft, y: newTrack }; - - // Double-check snapped position doesn't cause collision - if (!checkCollisionWithTrack(snappedScrubber, scrubber.id)) { - onUpdate(snappedScrubber); - } else { - onUpdate(rawScrubber); - } - } else { - // Collision detected - try smart positioning - const collidingScrubber = collidingScrubbers[0]; // Handle first collision - const collidingStart = collidingScrubber.left; - const collidingEnd = collidingScrubber.left + collidingScrubber.width; - - // Determine which side of the colliding scrubber the mouse is closest to - const mouseCenter = rawNewLeft + scrubber.width / 2; - const collidingCenter = collidingStart + collidingScrubber.width / 2; - - let snapToLeft: number; - let snapToRight: number; - - if (mouseCenter < collidingCenter) { - // Mouse is on the left side - try snapping to left edge first - snapToLeft = collidingStart - scrubber.width; - snapToRight = collidingEnd; - } else { - // Mouse is on the right side - try snapping to right edge first - snapToRight = collidingEnd; - snapToLeft = collidingStart - scrubber.width; - } - - // Try the preferred side first - const preferredScrubber = mouseCenter < collidingCenter - ? { ...scrubber, left: Math.max(0, snapToLeft), y: newTrack } - : { ...scrubber, left: Math.min(snapToRight, timelineWidth - scrubber.width), y: newTrack }; - - if (!checkCollisionWithTrack(preferredScrubber, scrubber.id)) { - onUpdate(preferredScrubber); - } else { - // Try the other side - const alternateScrubber = mouseCenter < collidingCenter - ? { ...scrubber, left: Math.min(snapToRight, timelineWidth - scrubber.width), y: newTrack } - : { ...scrubber, left: Math.max(0, snapToLeft), y: newTrack }; - - if (!checkCollisionWithTrack(alternateScrubber, scrubber.id)) { - onUpdate(alternateScrubber); - } - // If both sides are blocked, don't update (scrubber stops) - } - } + // Apply snapping to the position + const snappedLeft = findSnapPoint(rawNewLeft, scrubber.id); + const updatedScrubber = { ...scrubber, left: snappedLeft, y: newTrack }; + + // Let the timeline handle collision detection and connected scrubber logic + onUpdate(updatedScrubber); // Auto-scroll when dragging near edges if (containerRef.current) { @@ -317,10 +199,7 @@ export const Scrubber: React.FC = ({ } const newScrubber = { ...scrubber, left: newLeft, width: newWidth }; - - if (!checkCollisionWithTrack(newScrubber, scrubber.id)) { - onUpdate(newScrubber); - } + onUpdate(newScrubber); } else if (resizeMode === "right") { let newWidth = dragStateRef.current.startWidth + deltaX; @@ -346,10 +225,7 @@ export const Scrubber: React.FC = ({ } const newScrubber = { ...scrubber, width: newWidth }; - - if (!checkCollisionWithTrack(newScrubber, scrubber.id)) { - onUpdate(newScrubber); - } + onUpdate(newScrubber); } } }, @@ -359,13 +235,11 @@ export const Scrubber: React.FC = ({ resizeMode, scrubber, timelineWidth, - checkCollisionWithTrack, onUpdate, expandTimeline, containerRef, findSnapPoint, trackCount, - otherScrubbers, ] ); @@ -440,12 +314,12 @@ export const Scrubber: React.FC = ({ const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + // Select the scrubber when right-clicked if (onSelect) { onSelect(scrubber.id); } - + // Get the position relative to the viewport setContextMenu({ visible: true, @@ -465,11 +339,11 @@ export const Scrubber: React.FC = ({ const handleContextMenuDelete = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + if (onDelete) { onDelete(scrubber.id); } - + // Close context menu setContextMenu({ visible: false, x: 0, y: 0 }); }, [onDelete, scrubber.id]); @@ -479,7 +353,7 @@ export const Scrubber: React.FC = ({ if (contextMenu.visible) { document.addEventListener("click", handleClickOutside); document.addEventListener("contextmenu", handleClickOutside); - + return () => { document.removeEventListener("click", handleClickOutside); document.removeEventListener("contextmenu", handleClickOutside); @@ -541,9 +415,8 @@ export const Scrubber: React.FC = ({ {/* Name and position tooltip when dragging - positioned above or below based on track */} {isDragging && (
{scrubber.name} • {(scrubber.left / pixelsPerSecond).toFixed(2)}s -{" "} {((scrubber.left + scrubber.width) / pixelsPerSecond).toFixed(2)}s @@ -553,16 +426,15 @@ export const Scrubber: React.FC = ({ {/* Resize tooltips when resizing - showing precise timestamps with dynamic positioning */} {isResizing && (
{resizeMode === "left" ? `Start: ${(scrubber.left / pixelsPerSecond).toFixed(2)}s` : `End: ${( - (scrubber.left + scrubber.width) / - pixelsPerSecond - ).toFixed(2)}s`} + (scrubber.left + scrubber.width) / + pixelsPerSecond + ).toFixed(2)}s`}
)}
diff --git a/app/components/timeline/TimelineTracks.tsx b/app/components/timeline/TimelineTracks.tsx index fe171fd..00c475e 100644 --- a/app/components/timeline/TimelineTracks.tsx +++ b/app/components/timeline/TimelineTracks.tsx @@ -127,9 +127,8 @@ export const TimelineTracks: React.FC = ({ {/* Scrollable Tracks Area */}
0 ? onScroll : undefined} > {timeline.tracks.length === 0 ? ( @@ -168,22 +167,23 @@ export const TimelineTracks: React.FC = ({ }} onDrop={(e) => { e.preventDefault(); - + const jsonString = e.dataTransfer.getData("application/json"); if (!jsonString) return; const data = JSON.parse(jsonString); - const timelineBounds = e.currentTarget.getBoundingClientRect(); - const tracksScrollContainer = e.currentTarget.parentElement; - if (!timelineBounds || !tracksScrollContainer) return; + // Use containerRef for consistent coordinate calculation like the ruler does + const scrollContainer = containerRef.current; + if (!scrollContainer) return; + + const containerBounds = scrollContainer.getBoundingClientRect(); + const scrollLeft = scrollContainer.scrollLeft || 0; + const scrollTop = scrollContainer.scrollTop || 0; - const scrollLeft = tracksScrollContainer.scrollLeft || 0; - const scrollTop = tracksScrollContainer.scrollTop || 0; - const dropXInTimeline = - e.clientX - timelineBounds.left + scrollLeft; - const dropYInTimeline = - e.clientY - timelineBounds.top + scrollTop; + // Calculate drop position relative to the scroll container, accounting for scroll + const dropXInTimeline = e.clientX - containerBounds.left + scrollLeft; + const dropYInTimeline = e.clientY - containerBounds.top + scrollTop; let trackIndex = Math.floor( dropYInTimeline / DEFAULT_TRACK_HEIGHT @@ -194,7 +194,7 @@ export const TimelineTracks: React.FC = ({ ); const trackId = timeline.tracks[trackIndex]?.id; - + if (!trackId) { console.warn("No tracks to drop on, or track detection failed."); return; @@ -218,11 +218,10 @@ export const TimelineTracks: React.FC = ({ > {/* Track background */}
= ({ top: `0px`, width: "1px", height: `${DEFAULT_TRACK_HEIGHT}px`, - backgroundColor: `rgb(var(--border) / ${ - gridIndex % 5 === 0 ? 0.5 : 0.25 - })`, + backgroundColor: `rgb(var(--border) / ${gridIndex % 5 === 0 ? 0.5 : 0.25 + })`, }} /> ))} @@ -268,10 +266,9 @@ export const TimelineTracks: React.FC = ({ {/* Scrubbers */} {getAllScrubbers().map((scrubber) => { // Get all transitions for the track containing this scrubber - const scrubberTrack = timeline.tracks.find(track => + const scrubberTrack = timeline.tracks.find(track => track.scrubbers.some(s => s.id === scrubber.id) ); - const trackTransitions = scrubberTrack?.transitions || []; return ( = ({ snapConfig={{ enabled: true, distance: 10 }} trackCount={timeline.tracks.length} pixelsPerSecond={pixelsPerSecond} - transitions={trackTransitions} /> ); })} @@ -300,11 +296,11 @@ export const TimelineTracks: React.FC = ({ const transitionComponents = []; for (const track of timeline.tracks) { for (const transition of track.transitions) { - const leftScrubber = transition.leftScrubberId ? + const leftScrubber = transition.leftScrubberId ? track.scrubbers.find(s => s.id === transition.leftScrubberId) || null : null; - const rightScrubber = transition.rightScrubberId ? + const rightScrubber = transition.rightScrubberId ? track.scrubbers.find(s => s.id === transition.rightScrubberId) || null : null; - + transitionComponents.push( = ({ let left = 0; const width = (transition.durationInFrames / 30) * pixelsPerSecond; // Convert frames to pixels let top = 0; + + // Define snap distance threshold (same as in useTimeline.ts) + const SNAP_DISTANCE = 10; if (leftScrubber && rightScrubber) { - // Position transition on the overlap area - // The overlap starts at the rightScrubber.left and has width equal to transition duration - const overlapStart = rightScrubber.left; - left = overlapStart; - top = leftScrubber.y * DEFAULT_TRACK_HEIGHT; + // Check if there's an overlap or a gap between scrubbers + const leftScrubberEnd = leftScrubber.left + leftScrubber.width; + const gap = rightScrubber.left - leftScrubberEnd; + + if (gap <= SNAP_DISTANCE) { + // Scrubbers are close enough - position transition on the overlap area + // The overlap starts at the rightScrubber.left and has width equal to transition duration + const overlapStart = rightScrubber.left; + left = overlapStart; + top = leftScrubber.y * DEFAULT_TRACK_HEIGHT; + } else { + // There's a gap - position the transition as an outro from the left scrubber + left = leftScrubber.left + leftScrubber.width - width; + top = leftScrubber.y * DEFAULT_TRACK_HEIGHT; + } } else if (leftScrubber) { // Transition after a scrubber (outro) - position at the end of left scrubber left = leftScrubber.left + leftScrubber.width - width; diff --git a/app/hooks/useTimeline.ts b/app/hooks/useTimeline.ts index b46da20..d6114db 100644 --- a/app/hooks/useTimeline.ts +++ b/app/hooks/useTimeline.ts @@ -271,28 +271,110 @@ export const useTimeline = () => { }, []); const handleDeleteScrubber = useCallback((scrubberId: string) => { + // Find all transitions connected to the scrubber being deleted + const connectedTransitionIds: string[] = []; + + timeline.tracks.forEach(track => { + track.transitions.forEach(transition => { + if (transition.leftScrubberId === scrubberId || transition.rightScrubberId === scrubberId) { + connectedTransitionIds.push(transition.id); + } + }); + }); + setTimeline((prev) => ({ ...prev, tracks: prev.tracks.map((track) => ({ ...track, + // Remove the scrubber scrubbers: track.scrubbers.filter( (scrubber) => scrubber.id !== scrubberId ), - })), + // Remove connected transitions + transitions: track.transitions.filter( + (transition) => !connectedTransitionIds.includes(transition.id) + ) + })).map(track => ({ + ...track, + // Clean up transition references in remaining scrubbers + scrubbers: track.scrubbers.map(scrubber => ({ + ...scrubber, + left_transition_id: connectedTransitionIds.includes(scrubber.left_transition_id || '') + ? null + : scrubber.left_transition_id, + right_transition_id: connectedTransitionIds.includes(scrubber.right_transition_id || '') + ? null + : scrubber.right_transition_id, + })) + })) })); - }, []); + + // Show feedback message + if (connectedTransitionIds.length > 0) { + toast.success(`Scrubber and ${connectedTransitionIds.length} connected transition${connectedTransitionIds.length > 1 ? 's' : ''} deleted`); + } else { + toast.success("Scrubber deleted"); + } + }, [timeline]); const handleDeleteScrubbersByMediaBinId = useCallback((mediaBinId: string) => { + // Find all scrubbers that will be deleted + const scrubbersToDelete: string[] = []; + timeline.tracks.forEach(track => { + track.scrubbers.forEach(scrubber => { + if (scrubber.sourceMediaBinId === mediaBinId) { + scrubbersToDelete.push(scrubber.id); + } + }); + }); + + // Find all transitions connected to any of the scrubbers being deleted + const connectedTransitionIds: string[] = []; + timeline.tracks.forEach(track => { + track.transitions.forEach(transition => { + if (scrubbersToDelete.includes(transition.leftScrubberId || '') || + scrubbersToDelete.includes(transition.rightScrubberId || '')) { + connectedTransitionIds.push(transition.id); + } + }); + }); + setTimeline((prev) => ({ ...prev, tracks: prev.tracks.map((track) => ({ ...track, + // Remove scrubbers with matching media bin ID scrubbers: track.scrubbers.filter( (scrubber) => scrubber.sourceMediaBinId !== mediaBinId ), - })), + // Remove connected transitions + transitions: track.transitions.filter( + (transition) => !connectedTransitionIds.includes(transition.id) + ) + })).map(track => ({ + ...track, + // Clean up transition references in remaining scrubbers + scrubbers: track.scrubbers.map(scrubber => ({ + ...scrubber, + left_transition_id: connectedTransitionIds.includes(scrubber.left_transition_id || '') + ? null + : scrubber.left_transition_id, + right_transition_id: connectedTransitionIds.includes(scrubber.right_transition_id || '') + ? null + : scrubber.right_transition_id, + })) + })) })); - }, []); + + // Show feedback message + if (scrubbersToDelete.length > 0) { + if (connectedTransitionIds.length > 0) { + toast.success(`${scrubbersToDelete.length} scrubber${scrubbersToDelete.length > 1 ? 's' : ''} and ${connectedTransitionIds.length} connected transition${connectedTransitionIds.length > 1 ? 's' : ''} deleted`); + } else { + toast.success(`${scrubbersToDelete.length} scrubber${scrubbersToDelete.length > 1 ? 's' : ''} deleted`); + } + } + }, [timeline]); const handleAddScrubberToTrack = useCallback( (trackId: string, newScrubber: ScrubberState) => { @@ -501,9 +583,9 @@ export const useTimeline = () => { return { valid: false, error: "Cannot place transitions next to each other" }; } - // Rule 3: Must have at least one sequence before or after - if (!leftScrubber && !rightScrubber) { - return { valid: false, error: "Must have at least one sequence before or after a transition" }; + // Rule 3: Must have both left and right scrubbers for a transition + if (!leftScrubber || !rightScrubber) { + return { valid: false, error: "You need 2 scrubbers for a transition" }; } return { valid: true }; @@ -512,7 +594,7 @@ export const useTimeline = () => { const getConnectedElements = useCallback((elementId: string): string[] => { const connected = new Set(); const toProcess = [elementId]; - + while (toProcess.length > 0) { const currentId = toProcess.pop()!; if (connected.has(currentId)) continue; @@ -577,12 +659,44 @@ export const useTimeline = () => { // Dropped on a scrubber if (dropPosition <= scrubber.left + scrubber.width / 2) { // Closer to left edge - leftScrubber = scrubbers[i - 1] || null; - rightScrubber = scrubber; + const prevScrubber = scrubbers[i - 1] || null; + if (prevScrubber) { + // Check gap to previous scrubber + const gap = scrubber.left - (prevScrubber.left + prevScrubber.width); + if (gap <= 10) { // Within snap distance + // Create transition between previous and current scrubber + leftScrubber = prevScrubber; + rightScrubber = scrubber; + } else { + // Too far - create intro transition for current scrubber + leftScrubber = null; + rightScrubber = scrubber; + } + } else { + // No previous scrubber - create intro transition + leftScrubber = null; + rightScrubber = scrubber; + } } else { // Closer to right edge - leftScrubber = scrubber; - rightScrubber = scrubbers[i + 1] || null; + const nextScrubber = scrubbers[i + 1] || null; + if (nextScrubber) { + // Check gap to next scrubber + const gap = nextScrubber.left - (scrubber.left + scrubber.width); + if (gap <= 10) { // Within snap distance + // Create transition between current and next scrubber + leftScrubber = scrubber; + rightScrubber = nextScrubber; + } else { + // Too far - create outro transition for current scrubber + leftScrubber = scrubber; + rightScrubber = null; + } + } else { + // No next scrubber - create outro transition + leftScrubber = scrubber; + rightScrubber = null; + } } break; } else if (i === 0 && dropPosition < scrubber.left) { @@ -634,6 +748,20 @@ export const useTimeline = () => { const pixelsPerSecond = getPixelsPerSecond(); const transitionWidthPx = (updatedTransition.durationInFrames / 30) * pixelsPerSecond; + // Define snap distance threshold (same as in TimelineTracks.tsx) + const SNAP_DISTANCE = 10; + + // Calculate the gap between scrubbers to determine if they should be moved together + const shouldMoveScrubbersTogetherForOverlap = () => { + if (!leftScrubber || !rightScrubber) return false; + + const leftScrubberEnd = leftScrubber.left + leftScrubber.width; + const gap = rightScrubber.left - leftScrubberEnd; + + // Only move scrubbers together if the gap is within snap distance + return gap <= SNAP_DISTANCE; + }; + // Add transition to track and update scrubber references with overlap positioning setTimeline(prev => ({ ...prev, @@ -648,14 +776,23 @@ export const useTimeline = () => { return { ...scrubber, right_transition_id: updatedTransition.id }; } if (scrubber.id === rightScrubber?.id) { - // Move the right scrubber to create overlap - // The right scrubber should start at (leftScrubber.end - transitionWidth) - const newLeft = leftScrubber ? (leftScrubber.left + leftScrubber.width - transitionWidthPx) : scrubber.left; - return { - ...scrubber, - left: newLeft, - left_transition_id: updatedTransition.id - }; + // Only move the right scrubber to create overlap if scrubbers are close enough + if (shouldMoveScrubbersTogetherForOverlap()) { + // Move the right scrubber to create overlap + // The right scrubber should start at (leftScrubber.end - transitionWidth) + const newLeft = leftScrubber ? (leftScrubber.left + leftScrubber.width - transitionWidthPx) : scrubber.left; + return { + ...scrubber, + left: newLeft, + left_transition_id: updatedTransition.id + }; + } else { + // If there's a large gap, don't move the scrubber - treat it as a transition for the scrubber alone + return { + ...scrubber, + left_transition_id: updatedTransition.id + }; + } } return scrubber; }) @@ -677,18 +814,30 @@ export const useTimeline = () => { const pixelsPerSecond = getPixelsPerSecond(); const transitionWidthPx = (transitionToDelete.durationInFrames / FPS) * pixelsPerSecond; + // Define snap distance threshold (same as in creation logic) + const SNAP_DISTANCE = 10; + // Find the right scrubber and calculate its movement const rightScrubber = track.scrubbers.find(s => s.id === transitionToDelete.rightScrubberId); const leftScrubber = track.scrubbers.find(s => s.id === transitionToDelete.leftScrubberId); - + let movementDistance = 0; let rightScrubberNewLeft = 0; - + let shouldRestorePosition = false; + if (rightScrubber && leftScrubber) { - // Calculate where the right scrubber should be positioned (no overlap) - rightScrubberNewLeft = leftScrubber.left + leftScrubber.width; - // Calculate how much we're moving the right scrubber - movementDistance = rightScrubberNewLeft - rightScrubber.left; + // Check if the scrubbers were originally close enough to be moved together + // We need to calculate what the original gap would have been before the transition was created + const currentGap = rightScrubber.left - (leftScrubber.left + leftScrubber.width); + const originalGap = currentGap + transitionWidthPx; + + if (originalGap <= SNAP_DISTANCE) { + // Scrubbers were moved together during creation, so restore them + shouldRestorePosition = true; + rightScrubberNewLeft = leftScrubber.left + leftScrubber.width + originalGap; + movementDistance = rightScrubberNewLeft - rightScrubber.left; + } + // If originalGap > SNAP_DISTANCE, the right scrubber was never moved, so don't move it back } return { @@ -702,19 +851,22 @@ export const useTimeline = () => { right_transition_id: scrubber.right_transition_id === transitionId ? null : scrubber.right_transition_id, }; - // If this scrubber was the right scrubber in the deleted transition, move it back - if (scrubber.id === transitionToDelete.rightScrubberId) { - return { ...baseScrubber, left: rightScrubberNewLeft }; - } + // Only move scrubbers if they were originally moved during transition creation + if (shouldRestorePosition) { + // If this scrubber was the right scrubber in the deleted transition, move it back + if (scrubber.id === transitionToDelete.rightScrubberId) { + return { ...baseScrubber, left: rightScrubberNewLeft }; + } - // If this scrubber comes after where the right scrubber originally ended, move it by the same distance - if (rightScrubber && scrubber.id !== rightScrubber.id && movementDistance > 0) { - // Check if this scrubber is on the same track as the transition - if (scrubber.y === rightScrubber.y) { - // Check if this scrubber starts at or after where the right scrubber originally ended - const rightScrubberOriginalEnd = rightScrubber.left + rightScrubber.width; - if (scrubber.left >= rightScrubberOriginalEnd) { - return { ...baseScrubber, left: scrubber.left + movementDistance }; + // If this scrubber comes after where the right scrubber originally ended, move it by the same distance + if (rightScrubber && scrubber.id !== rightScrubber.id && movementDistance > 0) { + // Check if this scrubber is on the same track as the transition + if (scrubber.y === rightScrubber.y) { + // Check if this scrubber starts at or after where the right scrubber originally ended + const rightScrubberOriginalEnd = rightScrubber.left + rightScrubber.width; + if (scrubber.left >= rightScrubberOriginalEnd) { + return { ...baseScrubber, left: scrubber.left + movementDistance }; + } } } } @@ -733,41 +885,190 @@ export const useTimeline = () => { toast.success("Transition deleted"); }, [getPixelsPerSecond]); + // Check if there's a transition between two scrubbers that allows overlap + const hasTransitionBetween = useCallback( + (scrubber1Id: string, scrubber2Id: string) => { + const allTransitions = timeline.tracks.flatMap(track => track.transitions); + return allTransitions.some( + (transition) => + (transition.leftScrubberId === scrubber1Id && transition.rightScrubberId === scrubber2Id) || + (transition.leftScrubberId === scrubber2Id && transition.rightScrubberId === scrubber1Id) + ); + }, + [timeline] + ); + + // Check collision with track awareness - allow overlap if transition exists + const checkCollisionWithTrack = useCallback( + (newScrubber: ScrubberState, excludeId?: string) => { + const allScrubbers = getAllScrubbers(); + return allScrubbers.some((other) => { + if (other.id === excludeId || other.y !== newScrubber.y) return false; + + const otherStart = other.left; + const otherEnd = other.left + other.width; + const newStart = newScrubber.left; + const newEnd = newScrubber.left + newScrubber.width; + + const hasOverlap = !(newEnd <= otherStart || newStart >= otherEnd); + + // If there's overlap, check if there's a transition that allows it + if (hasOverlap && hasTransitionBetween(newScrubber.id, other.id)) { + return false; // Allow overlap due to transition + } + + return hasOverlap; + }); + }, + [getAllScrubbers, hasTransitionBetween] + ); + + // Handle collision detection and smart positioning + const handleCollisionDetection = useCallback((updatedScrubber: ScrubberState, originalScrubber: ScrubberState, timelineWidth: number) => { + const allScrubbers = getAllScrubbers(); + const otherScrubbers = allScrubbers.filter(s => s.id !== updatedScrubber.id); + + // Find colliding scrubbers on the same track + const collidingScrubbers = otherScrubbers.filter(other => { + if (other.y !== updatedScrubber.y) return false; + const otherStart = other.left; + const otherEnd = other.left + other.width; + const newStart = updatedScrubber.left; + const newEnd = updatedScrubber.left + updatedScrubber.width; + return !(newEnd <= otherStart || newStart >= otherEnd); + }); + + if (collidingScrubbers.length === 0) { + // No collision - use the updated position + if (!checkCollisionWithTrack(updatedScrubber, updatedScrubber.id)) { + return updatedScrubber; + } + } else { + // Collision detected - try smart positioning + const collidingScrubber = collidingScrubbers[0]; // Handle first collision + const collidingStart = collidingScrubber.left; + const collidingEnd = collidingScrubber.left + collidingScrubber.width; + + // Determine which side of the colliding scrubber the mouse is closest to + const mouseCenter = updatedScrubber.left + updatedScrubber.width / 2; + const collidingCenter = collidingStart + collidingScrubber.width / 2; + + let snapToLeft: number; + let snapToRight: number; + + if (mouseCenter < collidingCenter) { + // Mouse is on the left side - try snapping to left edge first + snapToLeft = collidingStart - updatedScrubber.width; + snapToRight = collidingEnd; + } else { + // Mouse is on the right side - try snapping to right edge first + snapToRight = collidingEnd; + snapToLeft = collidingStart - updatedScrubber.width; + } + + // Try the preferred side first + const preferredScrubber = mouseCenter < collidingCenter + ? { ...updatedScrubber, left: Math.max(0, snapToLeft) } + : { ...updatedScrubber, left: Math.min(snapToRight, timelineWidth - updatedScrubber.width) }; + + if (!checkCollisionWithTrack(preferredScrubber, updatedScrubber.id)) { + return preferredScrubber; + } else { + // Try the other side + const alternateScrubber = mouseCenter < collidingCenter + ? { ...updatedScrubber, left: Math.min(snapToRight, timelineWidth - updatedScrubber.width) } + : { ...updatedScrubber, left: Math.max(0, snapToLeft) }; + + if (!checkCollisionWithTrack(alternateScrubber, updatedScrubber.id)) { + return alternateScrubber; + } + } + // If both sides are blocked, return original position (scrubber stops) + return originalScrubber; + } + + return updatedScrubber; + }, [getAllScrubbers, checkCollisionWithTrack]); + const handleUpdateScrubberWithLocking = useCallback((updatedScrubber: ScrubberState) => { const connectedElements = getConnectedElements(updatedScrubber.id); - const scrubberConnected = connectedElements.filter(id => + const scrubberConnected = connectedElements.filter(id => timeline.tracks.some(track => track.scrubbers.some(s => s.id === id)) ); - if (scrubberConnected.length > 1) { - // Calculate offset + // Check if this scrubber is connected to transitions (more than just itself) + const isConnectedToTransitions = scrubberConnected.length > 1; + + if (isConnectedToTransitions) { + // Handle collision detection for connected scrubbers as a group const originalScrubber = getAllScrubbers().find(s => s.id === updatedScrubber.id); if (!originalScrubber) return; const offsetX = updatedScrubber.left - originalScrubber.left; const offsetY = updatedScrubber.y - originalScrubber.y; - // Update all connected scrubbers with the same offset - setTimeline(prev => ({ - ...prev, - tracks: prev.tracks.map(track => ({ - ...track, - scrubbers: track.scrubbers.map(scrubber => { - if (scrubberConnected.includes(scrubber.id)) { - return { - ...scrubber, - left: scrubber.left + offsetX, - y: scrubber.y + offsetY, - }; - } - return scrubber; - }) - })) + // Calculate new positions for all connected scrubbers + const allScrubbers = getAllScrubbers(); + const connectedScrubbers = allScrubbers.filter(s => scrubberConnected.includes(s.id)); + const updatedConnectedScrubbers = connectedScrubbers.map(scrubber => ({ + ...scrubber, + left: scrubber.left + offsetX, + y: scrubber.y + offsetY, })); + + // Check if any of the connected scrubbers would collide with non-connected scrubbers + const hasCollision = updatedConnectedScrubbers.some(updatedConnectedScrubber => { + return allScrubbers.some(other => { + // Skip if other scrubber is also in the connected group + if (scrubberConnected.includes(other.id)) return false; + // Skip if on different tracks + if (other.y !== updatedConnectedScrubber.y) return false; + + const otherStart = other.left; + const otherEnd = other.left + other.width; + const newStart = updatedConnectedScrubber.left; + const newEnd = updatedConnectedScrubber.left + updatedConnectedScrubber.width; + + const hasOverlap = !(newEnd <= otherStart || newStart >= otherEnd); + + // If there's overlap, check if there's a transition that allows it + if (hasOverlap && hasTransitionBetween(updatedConnectedScrubber.id, other.id)) { + return false; // Allow overlap due to transition + } + + return hasOverlap; + }); + }); + + // Only update if there's no collision + if (!hasCollision) { + setTimeline(prev => ({ + ...prev, + tracks: prev.tracks.map(track => ({ + ...track, + scrubbers: track.scrubbers.map(scrubber => { + if (scrubberConnected.includes(scrubber.id)) { + return { + ...scrubber, + left: scrubber.left + offsetX, + y: scrubber.y + offsetY, + }; + } + return scrubber; + }) + })) + })); + } + // If there is a collision, don't move the connected scrubbers (they stay in place) } else { - handleUpdateScrubber(updatedScrubber); + // Run collision detection for standalone scrubbers + const originalScrubber = getAllScrubbers().find(s => s.id === updatedScrubber.id); + if (!originalScrubber) return; + + const finalScrubber = handleCollisionDetection(updatedScrubber, originalScrubber, timelineWidth); + handleUpdateScrubber(finalScrubber); } - }, [getConnectedElements, timeline, getAllScrubbers, handleUpdateScrubber]); + }, [getConnectedElements, timeline, getAllScrubbers, handleUpdateScrubber, handleCollisionDetection, timelineWidth, hasTransitionBetween]); return { timeline, diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 0f5d275..c50c661 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -90,7 +90,7 @@ export default function TimelineEditor() { const [width, setWidth] = useState(1920); const [height, setHeight] = useState(1080); const [isAutoSize, setIsAutoSize] = useState(false); - const [isChatMinimized, setIsChatMinimized] = useState(false); + const [isChatMinimized, setIsChatMinimized] = useState(true); const [chatMessages, setChatMessages] = useState([]); const [starCount, setStarCount] = useState(null); diff --git a/app/video-compositions/VideoPlayer.tsx b/app/video-compositions/VideoPlayer.tsx index 3de2e6f..c63b1c1 100644 --- a/app/video-compositions/VideoPlayer.tsx +++ b/app/video-compositions/VideoPlayer.tsx @@ -223,7 +223,7 @@ export function TimelineComposition({ transitionSeriesElements.push( @@ -249,7 +249,7 @@ export function TimelineComposition({ transitionSeriesElements.push( {mediaContent} @@ -280,7 +280,7 @@ export function TimelineComposition({ transitionSeriesElements.push(