mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Merge pull request #437 from ExtraBinoss/feat/timeline-scrubbing
feat/timeline-scrubbing : Mouse now scrubs timeline.
This commit is contained in:
@@ -3463,11 +3463,21 @@ export default function VideoEditor() {
|
||||
setAutoSuggestZoomsTrigger(0);
|
||||
}, []);
|
||||
|
||||
function handleSeek(time: number) {
|
||||
const video = videoPlaybackRef.current?.video;
|
||||
const handleSeek = useCallback((time: number, options: { pause?: boolean } = {}) => {
|
||||
const playback = videoPlaybackRef.current;
|
||||
const video = playback?.video;
|
||||
if (!video) return;
|
||||
|
||||
if (options.pause && !video.paused) {
|
||||
playback?.pause();
|
||||
}
|
||||
|
||||
video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000;
|
||||
}
|
||||
}, [mapTimelineTimeToSourceTime]);
|
||||
|
||||
const handleTimelineSeek = useCallback((time: number) => {
|
||||
handleSeek(time, { pause: true });
|
||||
}, [handleSeek]);
|
||||
|
||||
const handleSelectZoom = useCallback((id: string | null) => {
|
||||
setSelectedZoomId(id);
|
||||
@@ -6653,7 +6663,7 @@ export default function VideoEditor() {
|
||||
videoDuration={timelineDuration}
|
||||
currentTime={currentTime}
|
||||
playheadTime={timelinePlayheadTime}
|
||||
onSeek={handleSeek}
|
||||
onSeek={handleTimelineSeek}
|
||||
videoPath={videoPath}
|
||||
cursorTelemetry={normalizedCursorTelemetry}
|
||||
autoSuggestZoomsTrigger={autoSuggestZoomsTrigger}
|
||||
|
||||
@@ -102,6 +102,7 @@ export default function Item({
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onPointerDownCapture={() => onSelect?.()}
|
||||
data-timeline-item="true"
|
||||
className="group h-full"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -320,14 +321,20 @@ function PlaybackCursor({
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!timelineRef.current || !onSeek) return;
|
||||
let frameId: number | null = null;
|
||||
let lastMouseEvent: MouseEvent | null = null;
|
||||
|
||||
const updateSeek = () => {
|
||||
if (!lastMouseEvent || !timelineRef.current || !onSeek) {
|
||||
frameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
const clickX = lastMouseEvent.clientX - rect.left - sidebarWidth;
|
||||
|
||||
// Allow dragging outside to 0 or max, but clamp the value
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
@@ -347,6 +354,14 @@ function PlaybackCursor({
|
||||
}
|
||||
|
||||
onSeek(absoluteMs / 1000);
|
||||
frameId = null;
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
lastMouseEvent = e;
|
||||
if (frameId === null) {
|
||||
frameId = requestAnimationFrame(updateSeek);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
@@ -359,6 +374,9 @@ function PlaybackCursor({
|
||||
document.body.style.cursor = "ew-resize";
|
||||
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
@@ -427,9 +445,11 @@ function PlaybackCursor({
|
||||
function TimelineAxis({
|
||||
videoDurationMs,
|
||||
currentTimeMs,
|
||||
onMouseDown,
|
||||
}: {
|
||||
videoDurationMs: number;
|
||||
currentTimeMs: number;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
||||
}) {
|
||||
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
@@ -494,10 +514,11 @@ function TimelineAxis({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8 bg-editor-bg border-b border-foreground/10 relative overflow-hidden select-none"
|
||||
className="h-8 bg-editor-bg border-b border-foreground/10 relative overflow-hidden select-none cursor-ew-resize"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
{/* Minor Ticks */}
|
||||
{markers.minorTicks.map((time) => {
|
||||
@@ -645,6 +666,7 @@ function Timeline({
|
||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isTimelineHovered, setIsTimelineHovered] = useState(false);
|
||||
const [timelineHoverMs, setTimelineHoverMs] = useState<number | null>(null);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const [isZoomRowHovered, setIsZoomRowHovered] = useState(false);
|
||||
const [zoomRowHoverMs, setZoomRowHoverMs] = useState<number | null>(null);
|
||||
|
||||
@@ -656,12 +678,23 @@ function Timeline({
|
||||
[setTimelineRef],
|
||||
);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
const handleTimelineMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return;
|
||||
e.stopPropagation();
|
||||
if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) return;
|
||||
|
||||
// Only clear selection if clicking on empty space (not on items)
|
||||
// This is handled by event propagation - items stop propagation
|
||||
const rect = localTimelineRef.current.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
|
||||
if (clickX < 0) return;
|
||||
|
||||
setIsSeeking(true);
|
||||
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
onSeek(absoluteMs / 1000);
|
||||
|
||||
// Clear selection when clicking on empty space
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectClip?.(null);
|
||||
@@ -669,20 +702,13 @@ function Timeline({
|
||||
onSelectSpeed?.(null);
|
||||
onSelectAudio?.(null);
|
||||
onClearBlockSelection?.();
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
|
||||
if (clickX < 0) return;
|
||||
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
const timeInSeconds = absoluteMs / 1000;
|
||||
|
||||
onSeek(timeInSeconds);
|
||||
},
|
||||
[
|
||||
onSeek,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
range.start,
|
||||
pixelsToValue,
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectClip,
|
||||
@@ -690,13 +716,54 @@ function Timeline({
|
||||
onSelectSpeed,
|
||||
onSelectAudio,
|
||||
onClearBlockSelection,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
range.start,
|
||||
pixelsToValue,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isSeeking) return;
|
||||
|
||||
let frameId: number | null = null;
|
||||
let lastMouseEvent: MouseEvent | null = null;
|
||||
|
||||
const updateSeek = () => {
|
||||
if (!lastMouseEvent || !localTimelineRef.current || !onSeek || videoDurationMs <= 0) {
|
||||
frameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = localTimelineRef.current.getBoundingClientRect();
|
||||
const clickX = lastMouseEvent.clientX - rect.left - sidebarWidth;
|
||||
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
|
||||
onSeek(absoluteMs / 1000);
|
||||
frameId = null;
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
lastMouseEvent = e;
|
||||
if (frameId === null) {
|
||||
frameId = requestAnimationFrame(updateSeek);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsSeeking(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isSeeking, onSeek, videoDurationMs, sidebarWidth, pixelsToValue, range.start]);
|
||||
|
||||
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
||||
const clipItems = items.filter((item) => item.rowId === CLIP_ROW_ID);
|
||||
const annotationItems = items.filter((item) => isAnnotationTrackRowId(item.rowId));
|
||||
@@ -845,6 +912,29 @@ function Timeline({
|
||||
[canPlaceZoomAtMs, onAddZoomAtMs, videoDurationMs, zoomRowHoverMs],
|
||||
);
|
||||
|
||||
const handleBackgroundMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Do not clear selection if clicking on an item
|
||||
if ((e.target as HTMLElement).closest("[data-timeline-item]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectClip?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
onSelectAudio?.(null);
|
||||
onClearBlockSelection?.();
|
||||
}, [
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectClip,
|
||||
onSelectAnnotation,
|
||||
onSelectSpeed,
|
||||
onSelectAudio,
|
||||
onClearBlockSelection,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
@@ -852,13 +942,18 @@ function Timeline({
|
||||
...style,
|
||||
height: `max(100%, ${timelineContentMinHeightPx}px, calc(${TIMELINE_AXIS_HEIGHT_PX}px + (100% - ${TIMELINE_AXIS_HEIGHT_PX}px) * ${timelineViewportStretchFactor}))`,
|
||||
}}
|
||||
className="select-none bg-editor-bg relative cursor-pointer group flex flex-col"
|
||||
onClick={handleTimelineClick}
|
||||
className="select-none bg-editor-bg relative group flex flex-col"
|
||||
onMouseEnter={handleTimelineMouseEnter}
|
||||
onMouseMove={handleTimelineMouseMove}
|
||||
onMouseLeave={handleTimelineMouseLeave}
|
||||
onMouseDown={handleBackgroundMouseDown}
|
||||
>
|
||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--foreground)/0.03)_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||
<TimelineAxis
|
||||
videoDurationMs={videoDurationMs}
|
||||
currentTimeMs={currentTimeMs}
|
||||
onMouseDown={handleTimelineMouseDown}
|
||||
/>
|
||||
<PlaybackCursor
|
||||
currentTimeMs={currentTimeMs}
|
||||
videoDurationMs={videoDurationMs}
|
||||
|
||||
Reference in New Issue
Block a user