diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 6512637..4a909ce 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -45,6 +45,7 @@ export function TimelineElement({ const { mediaItems } = useMediaStore(); const { updateElementTrim, + updateElementDuration, removeElementFromTrack, dragState, splitElement, @@ -67,6 +68,7 @@ export function TimelineElement({ track, zoomLevel, onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, }); const effectiveDuration = diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts index 3882a8f..0e40997 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline"; +import { useMediaStore } from "@/stores/media-store"; interface UseTimelineElementResizeProps { element: TimelineElement; @@ -11,6 +12,11 @@ interface UseTimelineElementResizeProps { trimStart: number, trimEnd: number ) => void; + onUpdateDuration: ( + trackId: string, + elementId: string, + duration: number + ) => void; } export function useTimelineElementResize({ @@ -18,8 +24,10 @@ export function useTimelineElementResize({ track, zoomLevel, onUpdateTrim, + onUpdateDuration, }: UseTimelineElementResizeProps) { const [resizing, setResizing] = useState(null); + const { mediaItems } = useMediaStore(); // Set up document-level mouse listeners during resize (like proper drag behavior) useEffect(() => { @@ -60,6 +68,30 @@ export function useTimelineElementResize({ }); }; + const canExtendElementDuration = () => { + // Text elements can always be extended + if (element.type === "text") { + return true; + } + + // Media elements - check the media type + if (element.type === "media") { + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem) return false; + + // Images can be extended (static content) + if (mediaItem.type === "image") { + return true; + } + + // Videos and audio cannot be extended beyond their natural duration + // (no additional content exists) + return false; + } + + return false; + }; + const updateTrimFromMouseMove = (e: { clientX: number }) => { if (!resizing) return; @@ -68,19 +100,48 @@ export function useTimelineElementResize({ const deltaTime = deltaX / (50 * zoomLevel); if (resizing.side === "left") { + // Left resize - only trim within original duration const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1; const calculated = resizing.initialTrimStart + deltaTime; const newTrimStart = Math.max(0, Math.min(maxAllowed, calculated)); onUpdateTrim(track.id, element.id, newTrimStart, resizing.initialTrimEnd); } else { - // For right resize (expanding element), allow trimEnd to go to 0 but cap at element duration + // Right resize - can extend duration for supported element types const calculated = resizing.initialTrimEnd - deltaTime; - // Prevent negative trim AND prevent trimEnd from exceeding element duration - const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible - const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated)); - onUpdateTrim(track.id, element.id, resizing.initialTrimStart, newTrimEnd); + if (calculated < 0) { + // We're trying to extend beyond original duration + if (canExtendElementDuration()) { + // Extend the duration instead of reducing trimEnd further + const extensionNeeded = Math.abs(calculated); + const newDuration = element.duration + extensionNeeded; + const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending + + // Update duration first, then trim + onUpdateDuration(track.id, element.id, newDuration); + onUpdateTrim( + track.id, + element.id, + resizing.initialTrimStart, + newTrimEnd + ); + } else { + // Can't extend - just set trimEnd to 0 (maximum possible extension) + onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0); + } + } 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)); + + onUpdateTrim( + track.id, + element.id, + resizing.initialTrimStart, + newTrimEnd + ); + } } }; diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 6d1a0bd..6de24c8 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -87,6 +87,11 @@ interface TimelineStore { trimStart: number, trimEnd: number ) => void; + updateElementDuration: ( + trackId: string, + elementId: string, + duration: number + ) => void; updateElementStartTime: ( trackId: string, elementId: string, @@ -284,7 +289,9 @@ export const useTimelineStore = create((set, get) => { removeTrack: (trackId) => { get().pushHistory(); - updateTracksAndSave(get()._tracks.filter((track) => track.id !== trackId)); + updateTracksAndSave( + get()._tracks.filter((track) => track.id !== trackId) + ); }, addElementToTrack: (trackId, elementData) => { @@ -439,6 +446,22 @@ export const useTimelineStore = create((set, get) => { ); }, + updateElementDuration: (trackId, elementId, duration) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId ? { ...element, duration } : element + ), + } + : track + ) + ); + }, + updateElementStartTime: (trackId, elementId, startTime) => { get().pushHistory(); updateTracksAndSave(