From 9fa9f406fe2722cec1e0d65d9b3868acd1e99f87 Mon Sep 17 00:00:00 2001 From: Simon Orzel Date: Thu, 17 Jul 2025 01:59:39 +0200 Subject: feat: splits working code --- .../editor/timeline/timeline-content.tsx | 123 +++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 apps/web/src/components/editor/timeline/timeline-content.tsx (limited to 'apps/web/src/components/editor/timeline/timeline-content.tsx') diff --git a/apps/web/src/components/editor/timeline/timeline-content.tsx b/apps/web/src/components/editor/timeline/timeline-content.tsx new file mode 100644 index 00000000..a1fd6906 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-content.tsx @@ -0,0 +1,123 @@ +import { SelectionBox } from "../selection-box"; +import { TimelinePlayhead } from "./timeline-playhead"; +import { SnapIndicator } from "../snap-indicator"; +import { TimelineRuler } from "./timeline-ruler"; +import { TimelineTracksArea } from "./timeline-tracks-area"; +import type { TimelineTrack } from "@/types/timeline"; +import type { SnapPoint } from "@/hooks/use-timeline-snapping"; + +export interface TimelineContentProps { + dynamicTimelineWidth: number; + tracks: TimelineTrack[]; + duration: number; + zoomLevel: number; + currentTime: number; + seek: (time: number) => void; + rulerRef: React.RefObject; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + playheadRef: React.RefObject; + trackLabelsRef: React.RefObject; + timelineRef: React.RefObject; + handleSelectionMouseDown: (e: React.MouseEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; + handleSnapPointChange: (snapPoint: SnapPoint | null) => void; + clearSelectedElements: () => void; + selectionBox: { + startPos: { x: number; y: number } | null; + currentPos: { x: number; y: number } | null; + isActive: boolean; + } | null; + tracksContainerRef: React.RefObject; + currentSnapPoint: SnapPoint | null; + showSnapIndicator: boolean; +} + +export function TimelineContent({ + dynamicTimelineWidth, + tracks, + duration, + zoomLevel, + currentTime, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + trackLabelsRef, + timelineRef, + handleSelectionMouseDown, + handleTimelineContentClick, + handleSnapPointChange, + clearSelectedElements, + selectionBox, + tracksContainerRef, + currentSnapPoint, + showSnapIndicator, +}: TimelineContentProps) { + return ( +
+
0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : 'grid-rows-[20px_1fr]'}`}> + + {/* Top-Left Corner (Empty space above track labels) */} +
+ + {/* Top Row (Sticky Ruler Header) */} + + + {/* Track Rows */} + +
+ + {/* Overlay Components */} + + + + + +
+ ); +} -- cgit v1.3.1 From 98b2103286f04d67db1d57f9e3330efa5d41fdb7 Mon Sep 17 00:00:00 2001 From: Simon Orzel Date: Thu, 17 Jul 2025 02:25:24 +0200 Subject: feat: working --- .../editor/timeline/timeline-action-handlers.tsx | 42 +++++-- .../editor/timeline/timeline-content-click.tsx | 11 -- .../editor/timeline/timeline-content.tsx | 6 +- .../editor/timeline/timeline-playhead.tsx | 17 +-- .../components/editor/timeline/timeline-ruler.tsx | 137 +++++++++++---------- .../editor/timeline/timeline-tracks-area.tsx | 2 +- 6 files changed, 110 insertions(+), 105 deletions(-) (limited to 'apps/web/src/components/editor/timeline/timeline-content.tsx') diff --git a/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx b/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx index ca12d71a..c9560507 100644 --- a/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx +++ b/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx @@ -1,12 +1,11 @@ +import { useMemo } from "react"; import { toast } from "sonner"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; export function useTimelineActionHandlers() { const { tracks, - addTrack, addElementToTrack, removeElementFromTrack, selectedElements, @@ -18,6 +17,29 @@ export function useTimelineActionHandlers() { } = useTimelineStore(); const { currentTime } = usePlaybackStore(); + // Create optimized lookup maps for O(1) access instead of O(n) find operations + const trackMap = useMemo(() => { + const map = new Map(); + tracks.forEach(track => { + map.set(track.id, track); + // Also create element lookup for this track + const elementMap = new Map(); + track.elements.forEach(element => { + elementMap.set(element.id, element); + }); + map.set(`${track.id}_elements`, elementMap); + }); + return map; + }, [tracks]); + + // Helper function for O(1) track/element lookup + const findTrackAndElement = (trackId: string, elementId: string) => { + const track = trackMap.get(trackId); + const elementMap = trackMap.get(`${trackId}_elements`); + const element = elementMap?.get(elementId); + return { track, element }; + }; + // Action handlers for toolbar const handleSplitSelected = () => { if (selectedElements.length === 0) { @@ -26,8 +48,7 @@ export function useTimelineActionHandlers() { } let splitCount = 0; selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + const { track, element } = findTrackAndElement(trackId, elementId); if (element && track) { const effectiveStart = element.startTime; const effectiveEnd = @@ -53,11 +74,8 @@ export function useTimelineActionHandlers() { const canDuplicate = selectedElements.length === 1; if (!canDuplicate) return; - const newSelections: { trackId: string; elementId: string }[] = []; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); + const { element } = findTrackAndElement(trackId, elementId); if (element) { const newStartTime = @@ -91,8 +109,7 @@ export function useTimelineActionHandlers() { return; } const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + const { element } = findTrackAndElement(trackId, elementId); if (!element) return; const effectiveStart = element.startTime; const effectiveEnd = @@ -111,8 +128,7 @@ export function useTimelineActionHandlers() { return; } const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + const { element } = findTrackAndElement(trackId, elementId); if (!element) return; const effectiveStart = element.startTime; const effectiveEnd = @@ -131,7 +147,7 @@ export function useTimelineActionHandlers() { return; } const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); + const { track } = findTrackAndElement(trackId, elementId); if (!track || track.type !== "media") { toast.error("Select a media element to separate audio"); return; diff --git a/apps/web/src/components/editor/timeline/timeline-content-click.tsx b/apps/web/src/components/editor/timeline/timeline-content-click.tsx index 9b86effc..8fb1ce2b 100644 --- a/apps/web/src/components/editor/timeline/timeline-content-click.tsx +++ b/apps/web/src/components/editor/timeline/timeline-content-click.tsx @@ -30,16 +30,6 @@ export function useTimelineContentClick({ // Timeline content click to seek handler const handleTimelineContentClick = useCallback( (e: React.MouseEvent) => { - console.log( - JSON.stringify({ - timelineClick: { - isSelecting, - justFinishedSelecting, - willReturn: isSelecting || justFinishedSelecting, - }, - }) - ); - // Don't seek if this was a selection box operation if (isSelecting || justFinishedSelecting) { return; @@ -62,7 +52,6 @@ export function useTimelineContentClick({ } // Clear selected elements when clicking empty timeline area - console.log(JSON.stringify({ clearingSelectedElements: true })); clearSelectedElements(); // Determine if we're clicking in ruler or tracks area diff --git a/apps/web/src/components/editor/timeline/timeline-content.tsx b/apps/web/src/components/editor/timeline/timeline-content.tsx index a1fd6906..ed0375da 100644 --- a/apps/web/src/components/editor/timeline/timeline-content.tsx +++ b/apps/web/src/components/editor/timeline/timeline-content.tsx @@ -56,8 +56,8 @@ export function TimelineContent({ showSnapIndicator, }: TimelineContentProps) { return ( -
-
0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : 'grid-rows-[20px_1fr]'}`}> +
+
0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : 'grid-rows-[20px_1fr]'}`}> {/* Top-Left Corner (Empty space above track labels) */}
@@ -85,7 +85,7 @@ export function TimelineContent({ />
- {/* Overlay Components */} + {/* Overlay Components - positioned absolutely relative to the timeline container */} 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; - const leftPosition = - trackLabelsWidth + - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + // Get dynamic track labels width, fallback to 192px (ml-48) if no tracks or no ref + const trackLabelsWidth = 192; // Fixed width from grid layout + const leftPosition = playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; return (
{ + // Calculate appropriate time interval based on zoom level + const getTimeInterval = (zoom: number) => { + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; + if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in + if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in + if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom + if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out + if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out + if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out + return 30; // Every 30s when extremely zoomed out + }; + + const formatTime = (seconds: number, interval: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (minutes > 0) { + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (interval >= 1) { + return `${Math.floor(secs)}s`; + } else { + return `${secs.toFixed(1)}s`; + } + }; + + const interval = getTimeInterval(zoomLevel); + const markerCount = Math.ceil(duration / interval) + 1; + const markers = []; + + for (let i = 0; i < markerCount; i++) { + const time = i * interval; + if (time > duration) break; + + const isMainMarker = time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + markers.push( +
+ + {formatTime(time, interval)} + +
+ ); + } + + return markers; + }, [duration, zoomLevel]); // Only recalculate when duration or zoomLevel changes + return (
- {(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = - TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; - - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; - - return Array.from({ length: markerCount }, (_, i) => { - const time = i * interval; - if (time > duration) return null; - - const isMainMarker = - time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - - return ( -
- - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (minutes > 0) { - return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { - return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; - } - }; - return formatTime(time); - })()} - -
- ); - }).filter(Boolean); - })()} + {rulerMarkers}
); diff --git a/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx b/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx index 293c8cc9..0e11f366 100644 --- a/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx +++ b/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx @@ -35,7 +35,7 @@ export function TimelineTracksArea({ {/* Left Column (Sticky Track Labels) */}
-- cgit v1.3.1 From 167f366c6d791dfc054d64f50e83828960668e55 Mon Sep 17 00:00:00 2001 From: Simon Orzel Date: Thu, 17 Jul 2025 03:00:38 +0200 Subject: feat: bunx biome format --write . --- .../editor/timeline/timeline-action-handlers.tsx | 342 +++++++++--------- .../editor/timeline/timeline-content-click.tsx | 191 +++++----- .../editor/timeline/timeline-content.tsx | 212 +++++------ .../editor/timeline/timeline-drag-handlers.tsx | 233 +++++++------ .../editor/timeline/timeline-playhead.tsx | 243 ++++++------- .../components/editor/timeline/timeline-ruler.tsx | 214 ++++++------ .../editor/timeline/timeline-scroll-sync.tsx | 102 +++--- .../editor/timeline/timeline-toolbar.tsx | 388 ++++++++++----------- .../editor/timeline/timeline-tracks-area.tsx | 150 ++++---- .../editor/timeline/timeline-wheel-handler.tsx | 38 +- .../src/components/editor/timeline/track-icon.tsx | 26 +- 11 files changed, 1064 insertions(+), 1075 deletions(-) (limited to 'apps/web/src/components/editor/timeline/timeline-content.tsx') diff --git a/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx b/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx index c9560507..0e7323d3 100644 --- a/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx +++ b/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx @@ -4,175 +4,175 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; export function useTimelineActionHandlers() { - const { - tracks, - addElementToTrack, - removeElementFromTrack, - selectedElements, - clearSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); - - // Create optimized lookup maps for O(1) access instead of O(n) find operations - const trackMap = useMemo(() => { - const map = new Map(); - tracks.forEach(track => { - map.set(track.id, track); - // Also create element lookup for this track - const elementMap = new Map(); - track.elements.forEach(element => { - elementMap.set(element.id, element); - }); - map.set(`${track.id}_elements`, elementMap); - }); - return map; - }, [tracks]); - - // Helper function for O(1) track/element lookup - const findTrackAndElement = (trackId: string, elementId: string) => { - const track = trackMap.get(trackId); - const elementMap = trackMap.get(`${trackId}_elements`); - const element = elementMap?.get(elementId); - return { track, element }; - }; - - // Action handlers for toolbar - const handleSplitSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const { track, element } = findTrackAndElement(trackId, elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } - }; - - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; - - selectedElements.forEach(({ trackId, elementId }) => { - const { element } = findTrackAndElement(trackId, elementId); - - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - - // Create element without id (will be generated by store) - const { id, ...elementWithoutId } = element; - - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - - // We can't predict the new id, so just clear selection for now - // TODO: addElementToTrack could return the new element id - } - }); - - clearSelectedElements(); - }; - - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; - - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const { element } = findTrackAndElement(trackId, elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; - - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const { element } = findTrackAndElement(trackId, elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; - - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const { track } = findTrackAndElement(trackId, elementId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; - - const handleDeleteSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - selectedElements.forEach(({ trackId, elementId }) => { - removeElementFromTrack(trackId, elementId); - }); - clearSelectedElements(); - }; - - return { - handleSplitSelected, - handleDuplicateSelected, - handleFreezeSelected, - handleSplitAndKeepLeft, - handleSplitAndKeepRight, - handleSeparateAudio, - handleDeleteSelected, - }; + const { + tracks, + addElementToTrack, + removeElementFromTrack, + selectedElements, + clearSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); + + // Create optimized lookup maps for O(1) access instead of O(n) find operations + const trackMap = useMemo(() => { + const map = new Map(); + tracks.forEach((track) => { + map.set(track.id, track); + // Also create element lookup for this track + const elementMap = new Map(); + track.elements.forEach((element) => { + elementMap.set(element.id, element); + }); + map.set(`${track.id}_elements`, elementMap); + }); + return map; + }, [tracks]); + + // Helper function for O(1) track/element lookup + const findTrackAndElement = (trackId: string, elementId: string) => { + const track = trackMap.get(trackId); + const elementMap = trackMap.get(`${trackId}_elements`); + const element = elementMap?.get(elementId); + return { track, element }; + }; + + // Action handlers for toolbar + const handleSplitSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const { track, element } = findTrackAndElement(trackId, elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + selectedElements.forEach(({ trackId, elementId }) => { + const { element } = findTrackAndElement(trackId, elementId); + + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + + // Create element without id (will be generated by store) + const { id, ...elementWithoutId } = element; + + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + + // We can't predict the new id, so just clear selection for now + // TODO: addElementToTrack could return the new element id + } + }); + + clearSelectedElements(); + }; + + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; + + const handleSplitAndKeepLeft = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const { element } = findTrackAndElement(trackId, elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepLeft(trackId, elementId, currentTime); + }; + + const handleSplitAndKeepRight = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const { element } = findTrackAndElement(trackId, elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepRight(trackId, elementId, currentTime); + }; + + const handleSeparateAudio = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const { track } = findTrackAndElement(trackId, elementId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } + separateAudio(trackId, elementId); + }; + + const handleDeleteSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach(({ trackId, elementId }) => { + removeElementFromTrack(trackId, elementId); + }); + clearSelectedElements(); + }; + + return { + handleSplitSelected, + handleDuplicateSelected, + handleFreezeSelected, + handleSplitAndKeepLeft, + handleSplitAndKeepRight, + handleSeparateAudio, + handleDeleteSelected, + }; } diff --git a/apps/web/src/components/editor/timeline/timeline-content-click.tsx b/apps/web/src/components/editor/timeline/timeline-content-click.tsx index 8fb1ce2b..7f4676c7 100644 --- a/apps/web/src/components/editor/timeline/timeline-content-click.tsx +++ b/apps/web/src/components/editor/timeline/timeline-content-click.tsx @@ -1,115 +1,118 @@ import { useCallback } from "react"; import { useProjectStore } from "@/stores/project-store"; -import { TIMELINE_CONSTANTS, snapTimeToFrame } from "@/constants/timeline-constants"; +import { + TIMELINE_CONSTANTS, + snapTimeToFrame, +} from "@/constants/timeline-constants"; export interface TimelineContentClickProps { - duration: number; - zoomLevel: number; - seek: (time: number) => void; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - clearSelectedElements: () => void; - isSelecting: boolean; - justFinishedSelecting: boolean; - playheadRef: React.RefObject; + duration: number; + zoomLevel: number; + seek: (time: number) => void; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + clearSelectedElements: () => void; + isSelecting: boolean; + justFinishedSelecting: boolean; + playheadRef: React.RefObject; } export function useTimelineContentClick({ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - playheadRef, + duration, + zoomLevel, + seek, + rulerScrollRef, + tracksScrollRef, + clearSelectedElements, + isSelecting, + justFinishedSelecting, + playheadRef, }: TimelineContentClickProps) { - const { activeProject } = useProjectStore(); + const { activeProject } = useProjectStore(); - // Timeline content click to seek handler - const handleTimelineContentClick = useCallback( - (e: React.MouseEvent) => { - // Don't seek if this was a selection box operation - if (isSelecting || justFinishedSelecting) { - return; - } + // Timeline content click to seek handler + const handleTimelineContentClick = useCallback( + (e: React.MouseEvent) => { + // Don't seek if this was a selection box operation + if (isSelecting || justFinishedSelecting) { + return; + } - // Don't seek if clicking on timeline elements, but still deselect - if ((e.target as HTMLElement).closest(".timeline-element")) { - return; - } + // Don't seek if clicking on timeline elements, but still deselect + if ((e.target as HTMLElement).closest(".timeline-element")) { + return; + } - // Don't seek if clicking on playhead - if (playheadRef.current?.contains(e.target as Node)) { - return; - } + // Don't seek if clicking on playhead + if (playheadRef.current?.contains(e.target as Node)) { + return; + } - // Don't seek if clicking on track labels - if ((e.target as HTMLElement).closest("[data-track-labels]")) { - clearSelectedElements(); - return; - } + // Don't seek if clicking on track labels + if ((e.target as HTMLElement).closest("[data-track-labels]")) { + clearSelectedElements(); + return; + } - // Clear selected elements when clicking empty timeline area - clearSelectedElements(); + // Clear selected elements when clicking empty timeline area + clearSelectedElements(); - // Determine if we're clicking in ruler or tracks area - const isRulerClick = (e.target as HTMLElement).closest( - "[data-ruler-area]" - ); + // Determine if we're clicking in ruler or tracks area + const isRulerClick = (e.target as HTMLElement).closest( + "[data-ruler-area]", + ); - let mouseX: number; - let scrollLeft = 0; + let mouseX: number; + let scrollLeft = 0; - if (isRulerClick) { - // Calculate based on ruler position - const rulerContent = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!rulerContent) return; - const rect = rulerContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = rulerContent.scrollLeft; - } else { - // Calculate based on tracks content position - const tracksContent = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!tracksContent) return; - const rect = tracksContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = tracksContent.scrollLeft; - } + if (isRulerClick) { + // Calculate based on ruler position + const rulerContent = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + if (!rulerContent) return; + const rect = rulerContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = rulerContent.scrollLeft; + } else { + // Calculate based on tracks content position + const tracksContent = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + if (!tracksContent) return; + const rect = tracksContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = tracksContent.scrollLeft; + } - const rawTime = Math.max( - 0, - Math.min( - duration, - (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) - ) - ); + const rawTime = Math.max( + 0, + Math.min( + duration, + (mouseX + scrollLeft) / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ), + ); - // Use frame snapping for timeline clicking - const projectFps = activeProject?.fps || 30; - const time = snapTimeToFrame(rawTime, projectFps); + // Use frame snapping for timeline clicking + const projectFps = activeProject?.fps || 30; + const time = snapTimeToFrame(rawTime, projectFps); - seek(time); - }, - [ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - playheadRef, - activeProject?.fps, - ] - ); + seek(time); + }, + [ + duration, + zoomLevel, + seek, + rulerScrollRef, + tracksScrollRef, + clearSelectedElements, + isSelecting, + justFinishedSelecting, + playheadRef, + activeProject?.fps, + ], + ); - return { handleTimelineContentClick }; + return { handleTimelineContentClick }; } diff --git a/apps/web/src/components/editor/timeline/timeline-content.tsx b/apps/web/src/components/editor/timeline/timeline-content.tsx index ed0375da..0229c418 100644 --- a/apps/web/src/components/editor/timeline/timeline-content.tsx +++ b/apps/web/src/components/editor/timeline/timeline-content.tsx @@ -7,117 +7,121 @@ import type { TimelineTrack } from "@/types/timeline"; import type { SnapPoint } from "@/hooks/use-timeline-snapping"; export interface TimelineContentProps { - dynamicTimelineWidth: number; - tracks: TimelineTrack[]; - duration: number; - zoomLevel: number; - currentTime: number; - seek: (time: number) => void; - rulerRef: React.RefObject; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - playheadRef: React.RefObject; - trackLabelsRef: React.RefObject; - timelineRef: React.RefObject; - handleSelectionMouseDown: (e: React.MouseEvent) => void; - handleTimelineContentClick: (e: React.MouseEvent) => void; - handleSnapPointChange: (snapPoint: SnapPoint | null) => void; - clearSelectedElements: () => void; - selectionBox: { - startPos: { x: number; y: number } | null; - currentPos: { x: number; y: number } | null; - isActive: boolean; - } | null; - tracksContainerRef: React.RefObject; - currentSnapPoint: SnapPoint | null; - showSnapIndicator: boolean; + dynamicTimelineWidth: number; + tracks: TimelineTrack[]; + duration: number; + zoomLevel: number; + currentTime: number; + seek: (time: number) => void; + rulerRef: React.RefObject; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + playheadRef: React.RefObject; + trackLabelsRef: React.RefObject; + timelineRef: React.RefObject; + handleSelectionMouseDown: (e: React.MouseEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; + handleSnapPointChange: (snapPoint: SnapPoint | null) => void; + clearSelectedElements: () => void; + selectionBox: { + startPos: { x: number; y: number } | null; + currentPos: { x: number; y: number } | null; + isActive: boolean; + } | null; + tracksContainerRef: React.RefObject; + currentSnapPoint: SnapPoint | null; + showSnapIndicator: boolean; } export function TimelineContent({ - dynamicTimelineWidth, - tracks, - duration, - zoomLevel, - currentTime, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - trackLabelsRef, - timelineRef, - handleSelectionMouseDown, - handleTimelineContentClick, - handleSnapPointChange, - clearSelectedElements, - selectionBox, - tracksContainerRef, - currentSnapPoint, - showSnapIndicator, + dynamicTimelineWidth, + tracks, + duration, + zoomLevel, + currentTime, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + trackLabelsRef, + timelineRef, + handleSelectionMouseDown, + handleTimelineContentClick, + handleSnapPointChange, + clearSelectedElements, + selectionBox, + tracksContainerRef, + currentSnapPoint, + showSnapIndicator, }: TimelineContentProps) { - return ( -
-
0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : 'grid-rows-[20px_1fr]'}`}> + return ( +
+
0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : "grid-rows-[20px_1fr]"}`} + > + {/* Top-Left Corner (Empty space above track labels) */} +
- {/* Top-Left Corner (Empty space above track labels) */} -
+ {/* Top Row (Sticky Ruler Header) */} + - {/* Top Row (Sticky Ruler Header) */} - + {/* Track Rows */} + +
- {/* Track Rows */} - -
+ {/* Overlay Components - positioned absolutely relative to the timeline container */} + - {/* Overlay Components - positioned absolutely relative to the timeline container */} - + - - - -
- ); + +
+ ); } diff --git a/apps/web/src/components/editor/timeline/timeline-drag-handlers.tsx b/apps/web/src/components/editor/timeline/timeline-drag-handlers.tsx index e8c3dd9f..60cd7122 100644 --- a/apps/web/src/components/editor/timeline/timeline-drag-handlers.tsx +++ b/apps/web/src/components/editor/timeline/timeline-drag-handlers.tsx @@ -7,137 +7,138 @@ import { processMediaFiles } from "@/lib/media-processing"; import type { DragData } from "@/types/timeline"; export interface TimelineDragHandlersProps { - isDragOver: boolean; - setIsDragOver: (isDragOver: boolean) => void; - isProcessing: boolean; - setIsProcessing: (isProcessing: boolean) => void; - progress: number; - setProgress: (progress: number) => void; + isDragOver: boolean; + setIsDragOver: (isDragOver: boolean) => void; + isProcessing: boolean; + setIsProcessing: (isProcessing: boolean) => void; + progress: number; + setProgress: (progress: number) => void; } export function useTimelineDragHandlers({ - isDragOver, - setIsDragOver, - isProcessing, - setIsProcessing, - progress, - setProgress, + isDragOver, + setIsDragOver, + isProcessing, + setIsProcessing, + progress, + setProgress, }: TimelineDragHandlersProps) { - const { mediaItems, addMediaItem } = useMediaStore(); - const { activeProject } = useProjectStore(); - const dragCounterRef = useRef(0); + const { mediaItems, addMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const dragCounterRef = useRef(0); - const handleDragEnter = (e: React.DragEvent) => { - // When something is dragged over the timeline, show overlay - e.preventDefault(); - // Don't show overlay for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - dragCounterRef.current += 1; - if (!isDragOver) { - setIsDragOver(true); - } - }; + const handleDragEnter = (e: React.DragEvent) => { + // When something is dragged over the timeline, show overlay + e.preventDefault(); + // Don't show overlay for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); - // Don't update state for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } + // Don't update state for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; - const handleDrop = async (e: React.DragEvent) => { - // When media is dropped, add it as a new track/element - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; + const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/element + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; - // Ignore timeline element drags - they're handled by track-specific handlers - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - if (hasTimelineElement) { - return; - } + // Ignore timeline element drags - they're handled by track-specific handlers + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element", + ); + if (hasTimelineElement) { + return; + } - const itemData = e.dataTransfer.getData("application/x-media-item"); - if (itemData) { - try { - const dragData: DragData = JSON.parse(itemData); + const itemData = e.dataTransfer.getData("application/x-media-item"); + if (itemData) { + try { + const dragData: DragData = JSON.parse(itemData); - if (dragData.type === "text") { - // Always create new text track to avoid overlaps - useTimelineStore.getState().addTextToNewTrack(dragData); - } else { - // Handle media items - const mediaItem = mediaItems.find( - (item: any) => item.id === dragData.id - ); - if (!mediaItem) { - toast.error("Media item not found"); - return; - } + if (dragData.type === "text") { + // Always create new text track to avoid overlaps + useTimelineStore.getState().addTextToNewTrack(dragData); + } else { + // Handle media items + const mediaItem = mediaItems.find( + (item: any) => item.id === dragData.id, + ); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } - useTimelineStore.getState().addMediaToNewTrack(mediaItem); - } - } catch (error) { - console.error("Error parsing dropped item data:", error); - toast.error("Failed to add item to timeline"); - } - } else if (e.dataTransfer.files?.length > 0) { - // Handle file drops by creating new tracks - if (!activeProject) { - toast.error("No active project"); - return; - } + useTimelineStore.getState().addMediaToNewTrack(mediaItem); + } + } catch (error) { + console.error("Error parsing dropped item data:", error); + toast.error("Failed to add item to timeline"); + } + } else if (e.dataTransfer.files?.length > 0) { + // Handle file drops by creating new tracks + if (!activeProject) { + toast.error("No active project"); + return; + } - setIsProcessing(true); - setProgress(0); - try { - const processedItems = await processMediaFiles( - e.dataTransfer.files, - (p) => setProgress(p) - ); - for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( - (item) => - item.name === processedItem.name && item.url === processedItem.url - ); - if (addedItem) { - useTimelineStore.getState().addMediaToNewTrack(addedItem); - } - } - } catch (error) { - // Show error if file processing fails - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - } - }; + setIsProcessing(true); + setProgress(0); + try { + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p), + ); + for (const processedItem of processedItems) { + await addMediaItem(activeProject.id, processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && + item.url === processedItem.url, + ); + if (addedItem) { + useTimelineStore.getState().addMediaToNewTrack(addedItem); + } + } + } catch (error) { + // Show error if file processing fails + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + } + }; - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; - return { dragProps }; + return { dragProps }; } diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index 9cd1429c..5e531e9c 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -1,121 +1,122 @@ -"use client"; - -import { useRef } from "react"; -import { TimelineTrack } from "@/types/timeline"; -import { - TIMELINE_CONSTANTS, - getTotalTracksHeight, -} from "@/constants/timeline-constants"; -import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; - -interface TimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - tracks: TimelineTrack[]; - seek: (time: number) => void; - rulerRef: React.RefObject; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - trackLabelsRef?: React.RefObject; - timelineRef: React.RefObject; - playheadRef?: React.RefObject; - isSnappingToPlayhead?: boolean; -} - -export function TimelinePlayhead({ - currentTime, - duration, - zoomLevel, - tracks, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - trackLabelsRef, - timelineRef, - playheadRef: externalPlayheadRef, - isSnappingToPlayhead = false, -}: TimelinePlayheadProps) { - const internalPlayheadRef = useRef(null); - const playheadRef = externalPlayheadRef || internalPlayheadRef; - const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - // Use timeline container height minus a few pixels for breathing room - const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 8; // 8px padding from edges - - // Get dynamic track labels width, fallback to 192px (ml-48) if no tracks or no ref - const trackLabelsWidth = 192; // Fixed width from grid layout - const leftPosition = playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - - return ( - <> - {/* Playhead line container */} -
- {/* The playhead line spanning full height */} -
-
- - {/* Playhead dot indicator - separate container with highest z-index */} -
-
-
- - ); -} - -// Also export a hook for getting ruler handlers -export function useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: Omit) { - const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - return { handleRulerMouseDown, isDraggingRuler }; -} +"use client"; + +import { useRef } from "react"; +import { TimelineTrack } from "@/types/timeline"; +import { + TIMELINE_CONSTANTS, + getTotalTracksHeight, +} from "@/constants/timeline-constants"; +import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; + +interface TimelinePlayheadProps { + currentTime: number; + duration: number; + zoomLevel: number; + tracks: TimelineTrack[]; + seek: (time: number) => void; + rulerRef: React.RefObject; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + trackLabelsRef?: React.RefObject; + timelineRef: React.RefObject; + playheadRef?: React.RefObject; + isSnappingToPlayhead?: boolean; +} + +export function TimelinePlayhead({ + currentTime, + duration, + zoomLevel, + tracks, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + trackLabelsRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, +}: TimelinePlayheadProps) { + const internalPlayheadRef = useRef(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; + const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + // Use timeline container height minus a few pixels for breathing room + const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; + const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + + // Get dynamic track labels width, fallback to 192px (ml-48) if no tracks or no ref + const trackLabelsWidth = 192; // Fixed width from grid layout + const leftPosition = + playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + return ( + <> + {/* Playhead line container */} +
+ {/* The playhead line spanning full height */} +
+
+ + {/* Playhead dot indicator - separate container with highest z-index */} +
+
+
+ + ); +} + +// Also export a hook for getting ruler handlers +export function useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: Omit) { + const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + return { handleRulerMouseDown, isDraggingRuler }; +} diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx index a875fe0f..fbe5ae1f 100644 --- a/apps/web/src/components/editor/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/timeline/timeline-ruler.tsx @@ -3,123 +3,125 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useTimelinePlayheadRuler } from "./timeline-playhead"; export interface TimelineRulerProps { - duration: number; - zoomLevel: number; - currentTime: number; - seek: (time: number) => void; - rulerRef: React.RefObject; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - playheadRef: React.RefObject; - handleSelectionMouseDown: (e: React.MouseEvent) => void; - handleTimelineContentClick: (e: React.MouseEvent) => void; + duration: number; + zoomLevel: number; + currentTime: number; + seek: (time: number) => void; + rulerRef: React.RefObject; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + playheadRef: React.RefObject; + handleSelectionMouseDown: (e: React.MouseEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; } export function TimelineRuler({ - duration, - zoomLevel, - currentTime, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - handleSelectionMouseDown, - handleTimelineContentClick, + duration, + zoomLevel, + currentTime, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + handleSelectionMouseDown, + handleTimelineContentClick, }: TimelineRulerProps) { - // Timeline playhead ruler handlers - const { handleRulerMouseDown } = useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); + // Timeline playhead ruler handlers + const { handleRulerMouseDown } = useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); - // Memoize the expensive ruler markers calculation - const rulerMarkers = useMemo(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; + // Memoize the expensive ruler markers calculation + const rulerMarkers = useMemo(() => { + // Calculate appropriate time interval based on zoom level + const getTimeInterval = (zoom: number) => { + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; + if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in + if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in + if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom + if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out + if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out + if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out + return 30; // Every 30s when extremely zoomed out + }; - const formatTime = (seconds: number, interval: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; + const formatTime = (seconds: number, interval: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (minutes > 0) { - return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { - return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; - } - }; + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (minutes > 0) { + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (interval >= 1) { + return `${Math.floor(secs)}s`; + } else { + return `${secs.toFixed(1)}s`; + } + }; - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; - const markers = []; + const interval = getTimeInterval(zoomLevel); + const markerCount = Math.ceil(duration / interval) + 1; + const markers = []; - for (let i = 0; i < markerCount; i++) { - const time = i * interval; - if (time > duration) break; + for (let i = 0; i < markerCount; i++) { + const time = i * interval; + if (time > duration) break; - const isMainMarker = time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const isMainMarker = + time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + const leftPosition = + time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - markers.push( -
- - {formatTime(time, interval)} - -
- ); - } + markers.push( +
+ + {formatTime(time, interval)} + +
, + ); + } - return markers; - }, [duration, zoomLevel]); // Only recalculate when duration or zoomLevel changes + return markers; + }, [duration, zoomLevel]); // Only recalculate when duration or zoomLevel changes - return ( -
-
- {rulerMarkers} -
-
- ); + return ( +
+
+ {rulerMarkers} +
+
+ ); } diff --git a/apps/web/src/components/editor/timeline/timeline-scroll-sync.tsx b/apps/web/src/components/editor/timeline/timeline-scroll-sync.tsx index 473c6f3e..6537d909 100644 --- a/apps/web/src/components/editor/timeline/timeline-scroll-sync.tsx +++ b/apps/web/src/components/editor/timeline/timeline-scroll-sync.tsx @@ -1,57 +1,61 @@ import { useEffect, useRef } from "react"; export interface TimelineScrollSyncProps { - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; } export function useTimelineScrollSync({ - rulerScrollRef, - tracksScrollRef, + rulerScrollRef, + tracksScrollRef, }: TimelineScrollSyncProps) { - const isUpdatingRef = useRef(false); - const lastSyncRef = useRef(0); - - // --- Horizontal scroll synchronization between ruler and tracks --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - - if (!rulerViewport || !tracksViewport) return; - - // Throttled scroll handlers for better performance - const handleRulerScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastSyncRef.current < 16) return; // 60fps throttling - lastSyncRef.current = now; - - isUpdatingRef.current = true; - tracksViewport.scrollLeft = rulerViewport.scrollLeft; - isUpdatingRef.current = false; - }; - - const handleTracksScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastSyncRef.current < 16) return; // 60fps throttling - lastSyncRef.current = now; - - isUpdatingRef.current = true; - rulerViewport.scrollLeft = tracksViewport.scrollLeft; - isUpdatingRef.current = false; - }; - - rulerViewport.addEventListener("scroll", handleRulerScroll, { passive: true }); - tracksViewport.addEventListener("scroll", handleTracksScroll, { passive: true }); - - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - }; - }, [rulerScrollRef, tracksScrollRef]); - - return null; + const isUpdatingRef = useRef(false); + const lastSyncRef = useRef(0); + + // --- Horizontal scroll synchronization between ruler and tracks --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + + if (!rulerViewport || !tracksViewport) return; + + // Throttled scroll handlers for better performance + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastSyncRef.current < 16) return; // 60fps throttling + lastSyncRef.current = now; + + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport.scrollLeft; + isUpdatingRef.current = false; + }; + + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastSyncRef.current < 16) return; // 60fps throttling + lastSyncRef.current = now; + + isUpdatingRef.current = true; + rulerViewport.scrollLeft = tracksViewport.scrollLeft; + isUpdatingRef.current = false; + }; + + rulerViewport.addEventListener("scroll", handleRulerScroll, { + passive: true, + }); + tracksViewport.addEventListener("scroll", handleTracksScroll, { + passive: true, + }); + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + }; + }, [rulerScrollRef, tracksScrollRef]); + + return null; } diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index b3061fe7..da8b6107 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -1,219 +1,201 @@ import { Button } from "../../ui/button"; import { - Scissors, - ArrowLeftToLine, - ArrowRightToLine, - Trash2, - Snowflake, - Copy, - SplitSquareHorizontal, - Pause, - Play, - Lock, - LockOpen, + Scissors, + ArrowLeftToLine, + ArrowRightToLine, + Trash2, + Snowflake, + Copy, + SplitSquareHorizontal, + Pause, + Play, + Lock, + LockOpen, } from "lucide-react"; import { - Tooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider, + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, } from "../../ui/tooltip"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; export interface TimelineToolbarProps { - handleSplitSelected: () => void; - handleSplitAndKeepLeft: () => void; - handleSplitAndKeepRight: () => void; - handleSeparateAudio: () => void; - handleDuplicateSelected: () => void; - handleFreezeSelected: () => void; - handleDeleteSelected: () => void; + handleSplitSelected: () => void; + handleSplitAndKeepLeft: () => void; + handleSplitAndKeepRight: () => void; + handleSeparateAudio: () => void; + handleDuplicateSelected: () => void; + handleFreezeSelected: () => void; + handleDeleteSelected: () => void; } export function TimelineToolbar({ - handleSplitSelected, - handleSplitAndKeepLeft, - handleSplitAndKeepRight, - handleSeparateAudio, - handleDuplicateSelected, - handleFreezeSelected, - handleDeleteSelected, + handleSplitSelected, + handleSplitAndKeepLeft, + handleSplitAndKeepRight, + handleSeparateAudio, + handleDuplicateSelected, + handleFreezeSelected, + handleDeleteSelected, }: TimelineToolbarProps) { - const { - tracks, - addTrack, - addElementToTrack, - snappingEnabled, - toggleSnapping, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + const { + tracks, + addTrack, + addElementToTrack, + snappingEnabled, + toggleSnapping, + } = useTimelineStore(); + const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); - return ( -
-
- - - - - - - {isPlaying ? "Pause (Space)" : "Play (Space)"} - - -
-
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s -
- {tracks.length === 0 && ( - <> -
- - - - - - Add a test clip to try playback - - - - )} -
- - - - - Split element (Ctrl+S) - - - - - - Split and keep left (Ctrl+Q) - - - - - - Split and keep right (Ctrl+W) - - - - - - Separate audio (Ctrl+D) - - - - - - Duplicate element (Ctrl+D) - - - - - - Freeze frame (F) - - - - - - Delete element (Delete) - - -
-
- - - - - - Auto snapping - - -
-
- ); + return ( +
+
+ + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + +
+
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s +
+ {tracks.length === 0 && ( + <> +
+ + + + + Add a test clip to try playback + + + )} +
+ + + + + Split element (Ctrl+S) + + + + + + Split and keep left (Ctrl+Q) + + + + + + Split and keep right (Ctrl+W) + + + + + + Separate audio (Ctrl+D) + + + + + + Duplicate element (Ctrl+D) + + + + + + Freeze frame (F) + + + + + + Delete element (Delete) + + +
+
+ + + + + + Auto snapping + + +
+
+ ); } diff --git a/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx b/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx index 144eb0f3..9242bccd 100644 --- a/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx +++ b/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx @@ -1,10 +1,10 @@ import { Fragment } from "react"; import { Plus } from "lucide-react"; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, } from "../../ui/context-menu"; import { useTimelineStore } from "@/stores/timeline-store"; import { TimelineTrackContent } from "../timeline-track"; @@ -14,86 +14,76 @@ import type { TimelineTrack } from "@/types/timeline"; import type { SnapPoint } from "@/hooks/use-timeline-snapping"; export interface TimelineTracksAreaProps { - tracks: TimelineTrack[]; - zoomLevel: number; - handleSnapPointChange: (snapPoint: SnapPoint | null) => void; - clearSelectedElements: () => void; + tracks: TimelineTrack[]; + zoomLevel: number; + handleSnapPointChange: (snapPoint: SnapPoint | null) => void; + clearSelectedElements: () => void; } export function TimelineTracksArea({ - tracks, - zoomLevel, - handleSnapPointChange, - clearSelectedElements, + tracks, + zoomLevel, + handleSnapPointChange, + clearSelectedElements, }: TimelineTracksAreaProps) { - const { addTrack, toggleTrackMute } = useTimelineStore(); + const { addTrack, toggleTrackMute } = useTimelineStore(); - return ( - <> - {/* Track Rows */} - {tracks.map((track, index) => ( - - {/* Left Column (Sticky Track Labels) */} -
-
- -
- {track.muted && ( - - Muted - - )} -
+ return ( + <> + {/* Track Rows */} + {tracks.map((track, index) => ( + + {/* Left Column (Sticky Track Labels) */} +
+
+ +
+ {track.muted && ( + Muted + )} +
- {/* Scrollable Track Content */} - - -
{ - // If clicking empty area (not on a element), deselect all elements - if ( - !(e.target as HTMLElement).closest( - ".timeline-element" - ) - ) { - clearSelectedElements(); - } - }} - > - -
-
- - toggleTrackMute(track.id)} - > - {track.muted ? "Unmute Track" : "Mute Track"} - - - Track settings (soon) - - -
-
- ))} - {/* Add Track Button - spans full width */} -
addTrack('media')} - className="col-span-1 sticky left-0 w-full flex items-center border-b border-muted bg-card/[0.99] hover:bg-card/50 transition-colors cursor-pointer z-[89]" - style={{ height: `${getTrackHeight("media")}px` }} - > -
- -
-
- - ); + {/* Scrollable Track Content */} + + +
{ + // If clicking empty area (not on a element), deselect all elements + if (!(e.target as HTMLElement).closest(".timeline-element")) { + clearSelectedElements(); + } + }} + > + +
+
+ + toggleTrackMute(track.id)}> + {track.muted ? "Unmute Track" : "Mute Track"} + + Track settings (soon) + +
+
+ ))} + {/* Add Track Button - spans full width */} +
addTrack("media")} + className="col-span-1 sticky left-0 w-full flex items-center border-b border-muted bg-card/[0.99] hover:bg-card/50 transition-colors cursor-pointer z-[89]" + style={{ height: `${getTrackHeight("media")}px` }} + > +
+ +
+
+ + ); } diff --git a/apps/web/src/components/editor/timeline/timeline-wheel-handler.tsx b/apps/web/src/components/editor/timeline/timeline-wheel-handler.tsx index 417cbbe8..8036b96c 100644 --- a/apps/web/src/components/editor/timeline/timeline-wheel-handler.tsx +++ b/apps/web/src/components/editor/timeline/timeline-wheel-handler.tsx @@ -1,30 +1,32 @@ import { useEffect } from "react"; export interface TimelineWheelHandlerProps { - timelineRef: React.RefObject; - isInTimeline: boolean; - handleWheel: (e: React.WheelEvent) => void; + timelineRef: React.RefObject; + isInTimeline: boolean; + handleWheel: (e: React.WheelEvent) => void; } export function useTimelineWheelHandler({ - timelineRef, - isInTimeline, - handleWheel, + timelineRef, + isInTimeline, + handleWheel, }: TimelineWheelHandlerProps) { - useEffect(() => { - const timelineContainer = timelineRef.current; - if (!timelineContainer || !isInTimeline) return; + useEffect(() => { + const timelineContainer = timelineRef.current; + if (!timelineContainer || !isInTimeline) return; - const handleWheelCapture = (e: WheelEvent) => { - handleWheel(e as any); - }; + const handleWheelCapture = (e: WheelEvent) => { + handleWheel(e as any); + }; - timelineContainer.addEventListener("wheel", handleWheelCapture, { passive: false }); + timelineContainer.addEventListener("wheel", handleWheelCapture, { + passive: false, + }); - return () => { - timelineContainer.removeEventListener("wheel", handleWheelCapture); - }; - }, [handleWheel, isInTimeline, timelineRef]); + return () => { + timelineContainer.removeEventListener("wheel", handleWheelCapture); + }; + }, [handleWheel, isInTimeline, timelineRef]); - return null; + return null; } diff --git a/apps/web/src/components/editor/timeline/track-icon.tsx b/apps/web/src/components/editor/timeline/track-icon.tsx index e250a0d8..609c2849 100644 --- a/apps/web/src/components/editor/timeline/track-icon.tsx +++ b/apps/web/src/components/editor/timeline/track-icon.tsx @@ -2,17 +2,17 @@ import { Video, Music, TypeIcon } from "lucide-react"; import type { TimelineTrack } from "@/types/timeline"; export function TrackIcon({ track }: { track: TimelineTrack }) { - return ( - <> - {track.type === "media" && ( -