feat: working

This commit is contained in:
Simon Orzel 2025-07-17 02:25:24 +02:00
parent 9fa9f406fe
commit 98b2103286
6 changed files with 110 additions and 105 deletions

View File

@ -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;

View File

@ -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

View File

@ -56,8 +56,8 @@ export function TimelineContent({
showSnapIndicator,
}: TimelineContentProps) {
return (
<div className="min-w-max min-h-max" style={{ minWidth: `${dynamicTimelineWidth}px` }}>
<div className={`grid grid-cols-[192px_1fr] ${tracks.length > 0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : 'grid-rows-[20px_1fr]'}`}>
<div className="relative min-w-max min-h-max" style={{ minWidth: `${dynamicTimelineWidth}px` }}>
<div className={`h-full grid grid-cols-[192px_1fr] ${tracks.length > 0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : 'grid-rows-[20px_1fr]'}`}>
{/* Top-Left Corner (Empty space above track labels) */}
<div className="sticky top-0 left-0 border-inset bg-card/[0.99]"></div>
@ -85,7 +85,7 @@ export function TimelineContent({
/>
</div>
{/* Overlay Components */}
{/* Overlay Components - positioned absolutely relative to the timeline container */}
<SelectionBox
startPos={selectionBox?.startPos || null}
currentPos={selectionBox?.currentPos || null}

View File

@ -54,23 +54,18 @@ export function TimelinePlayhead({
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
const trackLabelsWidth =
tracks.length > 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 (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-[100] ml-48"
className="absolute pointer-events-auto z-[95]"
style={{
left: `${leftPosition}px`,
left: `${trackLabelsWidth + leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
bottom: 0,
width: "2px", // Slightly wider for better click target
}}
onMouseDown={handlePlayheadMouseDown}

View File

@ -1,3 +1,4 @@
import { useMemo } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelinePlayheadRuler } from "./timeline-playhead";
@ -38,82 +39,86 @@ export function TimelineRuler({
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
};
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(
<div
key={i}
className={`absolute top-0 bottom-0 z-[97] ${
isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
}`}
style={{ left: `${leftPosition}px` }}
>
<span
className={`absolute top-1 left-1 text-[0.6rem] z-[97] ${
isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
}`}
>
{formatTime(time, interval)}
</span>
</div>
);
}
return markers;
}, [duration, zoomLevel]); // Only recalculate when duration or zoomLevel changes
return (
<div
className="sticky top-0 bg-card/[0.99] border-b border-muted/30 z-[95]"
className="sticky top-0 bg-card/[0.99] border-b border-muted/30 z-[97]"
onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick}
data-ruler-area
>
<div
ref={rulerRef}
className="relative h-5 select-none cursor-default pb-1 z-[95]"
className="relative h-5 select-none cursor-default pb-1 z-[97]"
onMouseDown={handleRulerMouseDown}
>
{(() => {
// 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 (
<div
key={i}
className={`absolute top-0 bottom-0 z-[95] ${isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
}`}
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
>
<span
className={`absolute top-1 left-1 text-[0.6rem] z-[95] ${isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
}`}
>
{(() => {
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);
})()}
</span>
</div>
);
}).filter(Boolean);
})()}
{rulerMarkers}
</div>
</div>
);

View File

@ -35,7 +35,7 @@ export function TimelineTracksArea({
<Fragment key={track.id}>
{/* Left Column (Sticky Track Labels) */}
<div
className="sticky left-0 flex items-center border-b border-panel border-inset group bg-card/[0.99] z-[90]"
className="sticky left-0 flex items-center border-b border-panel border-inset group bg-card/[0.99] z-[96]"
style={{ height: `${getTrackHeight(track.type)}px` }}
>
<div className="flex items-center gap-2 px-2">