diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-07-10 23:55:08 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-07-10 23:55:08 +0200 |
| commit | bb65d4fb96d3726448100ea8d95e5eab92e6ddba (patch) | |
| tree | bc5a0c9378b25a8d758ed8b3bacc4957b97bddc6 /apps/web/src/components/editor | |
| parent | eabcdb0988c3fdf5f940cb24a3e817bb19d45512 (diff) | |
| download | opencut-bb65d4fb96d3726448100ea8d95e5eab92e6ddba.tar.gz | |
refactor: timeline playhead component and hook
Diffstat (limited to 'apps/web/src/components/editor')
| -rw-r--r-- | apps/web/src/components/editor/timeline-playhead.tsx | 125 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 180 |
2 files changed, 163 insertions, 142 deletions
diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx new file mode 100644 index 00000000..51dab0f3 --- /dev/null +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -0,0 +1,125 @@ +"use client";
+
+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<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+}
+
+export function TimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ tracks,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+}: TimelinePlayheadProps) {
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ });
+
+ return (
+ <>
+ {/* Playhead in ruler (scrubbable) */}
+ <div
+ className="playhead absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize"
+ style={{
+ left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ >
+ <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
+ </div>
+ </>
+ );
+}
+
+interface TimelinePlayheadTracksProps {
+ currentTime: number;
+ duration: number;
+ zoomLevel: number;
+ tracks: TimelineTrack[];
+ seek: (time: number) => void;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+}
+
+export function TimelinePlayheadTracks({
+ currentTime,
+ duration,
+ zoomLevel,
+ tracks,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+}: TimelinePlayheadTracksProps) {
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ });
+
+ if (tracks.length === 0) return null;
+
+ return (
+ <div
+ className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize"
+ style={{
+ left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ height: `${getTotalTracksHeight(tracks)}px`,
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ />
+ );
+}
+
+// Also export a hook for getting ruler handlers
+export function useTimelinePlayheadRuler({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+}: Omit<TimelinePlayheadProps, "tracks" | "dynamicTimelineWidth">) {
+ const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ });
+
+ return { handleRulerMouseDown, isDraggingRuler };
+}
+
+export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index f3e20fda..15c51879 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -44,6 +44,11 @@ import { SelectValue, } from "../ui/select"; import { TimelineTrackContent } from "./timeline-track"; +import { + TimelinePlayhead, + TimelinePlayheadTracks, + useTimelinePlayheadRuler, +} from "./timeline-playhead"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { getTrackHeight, @@ -109,14 +114,6 @@ export function Timeline() { additive: boolean; } | null>(null); - // Playhead scrubbing state - const [isScrubbing, setIsScrubbing] = useState(false); - const [scrubTime, setScrubTime] = useState<number | null>(null); - - // Add new state for ruler drag detection - const [isDraggingRuler, setIsDraggingRuler] = useState(false); - const [hasDraggedRuler, setHasDraggedRuler] = useState(false); - // Dynamic timeline width calculation based on playhead position and duration const dynamicTimelineWidth = Math.max( (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration @@ -131,6 +128,17 @@ export function Timeline() { const lastRulerSync = useRef(0); const lastTracksSync = useRef(0); + // Timeline playhead ruler handlers + const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + }); + // Update timeline duration when tracks change useEffect(() => { const totalDuration = getTotalDuration(); @@ -460,92 +468,6 @@ export function Timeline() { } }; - // --- Playhead Scrubbing Handlers --- - const handlePlayheadMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); // Prevent ruler drag from triggering - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel] - ); - - // Add new ruler mouse down handler - const handleRulerMouseDown = useCallback( - (e: React.MouseEvent) => { - // Only handle left mouse button - if (e.button !== 0) return; - - // Don't interfere if clicking on the playhead itself - if ((e.target as HTMLElement).closest(".playhead")) return; - - e.preventDefault(); - setIsDraggingRuler(true); - setHasDraggedRuler(false); - - // Start scrubbing immediately - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel] - ); - - const handleScrub = useCallback( - (e: MouseEvent | React.MouseEvent) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rect = ruler.getBoundingClientRect(); - const x = e.clientX - rect.left; - const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); - setScrubTime(time); - seek(time); // update video preview in real time - }, - [duration, zoomLevel, seek] - ); - - useEffect(() => { - if (!isScrubbing) return; - const onMouseMove = (e: MouseEvent) => { - handleScrub(e); - // Mark that we've dragged if ruler drag is active - if (isDraggingRuler) { - setHasDraggedRuler(true); - } - }; - const onMouseUp = (e: MouseEvent) => { - setIsScrubbing(false); - if (scrubTime !== null) seek(scrubTime); // finalize seek - setScrubTime(null); - - // Handle ruler click vs drag - if (isDraggingRuler) { - setIsDraggingRuler(false); - // If we didn't drag, treat it as a click-to-seek - if (!hasDraggedRuler) { - handleScrub(e); - } - setHasDraggedRuler(false); - } - }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [ - isScrubbing, - scrubTime, - seek, - handleScrub, - isDraggingRuler, - hasDraggedRuler, - ]); - - const playheadPosition = - isScrubbing && scrubTime !== null ? scrubTime : currentTime; - const dragProps = { onDragEnter: handleDragEnter, onDragOver: handleDragOver, @@ -718,33 +640,6 @@ export function Timeline() { }; }, []); - // --- Playhead auto-scroll effect --- - 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; - const playheadPx = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const viewportWidth = rulerViewport.clientWidth; - const scrollMin = 0; - const scrollMax = rulerViewport.scrollWidth - viewportWidth; - // Center the playhead if it's not visible (100px buffer) - const desiredScroll = Math.max( - scrollMin, - Math.min(scrollMax, playheadPx - viewportWidth / 2) - ); - if ( - playheadPx < rulerViewport.scrollLeft + 100 || - playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 - ) { - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }, [playheadPosition, duration, zoomLevel]); - return ( <div className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} @@ -1002,16 +897,17 @@ export function Timeline() { }).filter(Boolean); })()} - {/* Playhead in ruler (scrubbable) */} - <div - className="playhead absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize" - style={{ - left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - }} - onMouseDown={handlePlayheadMouseDown} - > - <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" /> - </div> + {/* Playhead in ruler */} + <TimelinePlayhead + currentTime={currentTime} + duration={duration} + zoomLevel={zoomLevel} + tracks={tracks} + seek={seek} + rulerRef={rulerRef} + rulerScrollRef={rulerScrollRef} + tracksScrollRef={tracksScrollRef} + /> </div> </ScrollArea> </div> @@ -1101,17 +997,17 @@ export function Timeline() { </ContextMenu> ))} - {/* Playhead for tracks area (scrubbable) */} - {tracks.length > 0 && ( - <div - className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize" - style={{ - left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - height: `${getTotalTracksHeight(tracks)}px`, - }} - onMouseDown={handlePlayheadMouseDown} - /> - )} + {/* Playhead for tracks area */} + <TimelinePlayheadTracks + currentTime={currentTime} + duration={duration} + zoomLevel={zoomLevel} + tracks={tracks} + seek={seek} + rulerRef={rulerRef} + rulerScrollRef={rulerScrollRef} + tracksScrollRef={tracksScrollRef} + /> </> )} </div> |
