From b00d6d5fb61229fcc035cc9680731f042d03b919 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Thu, 31 Jul 2025 23:47:10 +0200 Subject: fix: make timeline element resizing frame based instead of pixel based --- apps/web/src/hooks/use-timeline-element-resize.ts | 41 +++++++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) (limited to 'apps/web/src/hooks/use-timeline-element-resize.ts') diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts index e7d46dff..d57533e0 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -2,6 +2,8 @@ import { useState, useEffect } from "react"; import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; +import { useProjectStore } from "@/stores/project-store"; +import { snapTimeToFrame } from "@/constants/timeline-constants"; interface UseTimelineElementResizeProps { element: TimelineElement; @@ -109,6 +111,10 @@ export function useTimelineElementResize({ // Reasonable sensitivity for resize operations - similar to timeline scale const deltaTime = deltaX / (50 * zoomLevel); + // Get project FPS for frame snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + if (resizing.side === "left") { // Left resize - different behavior for media vs text/image elements const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1; @@ -116,9 +122,15 @@ export function useTimelineElementResize({ if (calculated >= 0) { // Normal trimming within available content - const newTrimStart = Math.min(maxAllowed, calculated); + const newTrimStart = snapTimeToFrame( + Math.min(maxAllowed, calculated), + projectFps + ); const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = element.startTime + trimDelta; + const newStartTime = snapTimeToFrame( + element.startTime + trimDelta, + projectFps + ); updateElementTrim( track.id, @@ -135,8 +147,14 @@ export function useTimelineElementResize({ const extensionAmount = Math.abs(calculated); const maxExtension = element.startTime; const actualExtension = Math.min(extensionAmount, maxExtension); - const newStartTime = element.startTime - actualExtension; - const newDuration = element.duration + actualExtension; + const newStartTime = snapTimeToFrame( + element.startTime - actualExtension, + projectFps + ); + const newDuration = snapTimeToFrame( + element.duration + actualExtension, + projectFps + ); // Keep trimStart at 0 and extend the element updateElementTrim( @@ -152,7 +170,10 @@ export function useTimelineElementResize({ // Video/Audio: can't extend beyond original content - limit to trimStart = 0 const newTrimStart = 0; const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = element.startTime + trimDelta; + const newStartTime = snapTimeToFrame( + element.startTime + trimDelta, + projectFps + ); updateElementTrim( track.id, @@ -173,7 +194,10 @@ export function useTimelineElementResize({ if (canExtendElementDuration()) { // Extend the duration instead of reducing trimEnd further const extensionNeeded = Math.abs(calculated); - const newDuration = element.duration + extensionNeeded; + const newDuration = snapTimeToFrame( + element.duration + extensionNeeded, + projectFps + ); const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending // Update duration first, then trim @@ -198,7 +222,10 @@ export function useTimelineElementResize({ } else { // Normal trimming within original duration const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible - const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated)); + const newTrimEnd = snapTimeToFrame( + Math.max(0, Math.min(maxTrimEnd, calculated)), + projectFps + ); updateElementTrim( track.id, -- cgit v1.3.1 From 09cb8c8d984e3e82f60e28b352508c876423fbdf Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 1 Aug 2025 09:51:54 +0200 Subject: fix: resize not matching with frames --- .../components/editor/timeline/timeline-track.tsx | 96 ++++++++++++---------- apps/web/src/hooks/use-timeline-element-resize.ts | 29 +++++-- 2 files changed, 75 insertions(+), 50 deletions(-) (limited to 'apps/web/src/hooks/use-timeline-element-resize.ts') diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 097fc051..1982d03e 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -68,45 +68,51 @@ export function TimelineTrackContent({ elementDuration: number, excludeElementId?: string ) => { - if (!snappingEnabled) { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - return snapTimeToFrame(dropTime, projectFps); - } + // Always apply frame snapping first + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + let finalTime = snapTimeToFrame(dropTime, projectFps); + + // Additionally apply element snapping if enabled + if (snappingEnabled) { + // Try snapping both start and end edges for drops + const startSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + true // snap to start edge + ); - // Try snapping both start and end edges for drops - const startSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - true // snap to start edge - ); + const endSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + false // snap to end edge + ); - const endSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - false // snap to end edge - ); + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; + // Only use element snapping if it found a snap point, otherwise keep frame-snapped time + if (bestSnapResult.snapPoint) { + finalTime = bestSnapResult.snappedTime; + } } - return bestSnapResult.snappedTime; + return finalTime; }; const timelineRef = useRef(null); @@ -148,9 +154,13 @@ export function TimelineTrackContent({ ); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - // Apply snapping if enabled - let finalTime = adjustedTime; + // Always apply frame snapping first + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + let finalTime = snapTimeToFrame(adjustedTime, projectFps); let snapPoint = null; + + // Additionally apply element snapping if enabled if (snappingEnabled) { // Find the element being dragged to get its duration let elementDuration = 5; // fallback duration @@ -196,18 +206,16 @@ export function TimelineTrackContent({ bestSnapResult = endSnapResult; } - finalTime = bestSnapResult.snappedTime; - snapPoint = bestSnapResult.snapPoint; + // Only use element snapping if it found a snap point, otherwise keep frame-snapped time + if (bestSnapResult.snapPoint) { + finalTime = bestSnapResult.snappedTime; + snapPoint = bestSnapResult.snapPoint; + } // Notify parent component about snap point change onSnapPointChange?.(snapPoint); } else { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - finalTime = snapTimeToFrame(adjustedTime, projectFps); - - // Clear snap point when not snapping + // Clear snap point when element snapping is disabled onSnapPointChange?.(null); } diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts index d57533e0..ba13e05a 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -221,17 +221,34 @@ export function useTimelineElementResize({ } } else { // Normal trimming within original duration - const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible - const newTrimEnd = snapTimeToFrame( - Math.max(0, Math.min(maxTrimEnd, calculated)), - projectFps + // Calculate the desired end time based on mouse movement + const currentEndTime = + element.startTime + + element.duration - + element.trimStart - + element.trimEnd; + const desiredEndTime = currentEndTime + deltaTime; + + // Snap the desired end time to frame + const snappedEndTime = snapTimeToFrame(desiredEndTime, projectFps); + + // Calculate what trimEnd should be to achieve this snapped end time + const newTrimEnd = Math.max( + 0, + element.duration - + element.trimStart - + (snappedEndTime - element.startTime) ); + // Ensure we don't trim more than available content (leave at least 0.1s visible) + const maxTrimEnd = element.duration - element.trimStart - 0.1; + const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd); + updateElementTrim( track.id, element.id, - resizing.initialTrimStart, - newTrimEnd, + element.trimStart, + finalTrimEnd, false ); } -- cgit v1.3.1