Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 126 additions & 7 deletions src/components/video-editor/timeline/TimelineEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,33 @@ function createInitialRange(totalMs: number): Range {
return { start: 0, end: FALLBACK_RANGE_MS };
}

function clampVisibleRange(candidate: Range, totalMs: number): Range {
if (totalMs <= 0) {
return candidate;
}

const span = Math.max(candidate.end - candidate.start, 1);

if (span >= totalMs) {
return { start: 0, end: totalMs };
}

const start = Math.max(0, Math.min(candidate.start, totalMs - span));
return { start, end: start + span };
}

function normalizeWheelDelta(delta: number, deltaMode: number, pageSizePx: number): number {
if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
return delta * 16;
}

if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return delta * pageSizePx;
}

return delta;
}

function formatTimeLabel(milliseconds: number, intervalMs: number) {
const totalSeconds = milliseconds / 1000;
const hours = Math.floor(totalSeconds / 3600);
Expand Down Expand Up @@ -204,18 +231,21 @@ function PlaybackCursor({
currentTimeMs,
videoDurationMs,
onSeek,
onRangeChange,
timelineRef,
keyframes = [],
}: {
currentTimeMs: number;
videoDurationMs: number;
onSeek?: (time: number) => void;
onRangeChange?: (updater: (previous: Range) => Range) => void;
timelineRef: React.RefObject<HTMLDivElement>;
keyframes?: { id: string; time: number }[];
}) {
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
const [isDragging, setIsDragging] = useState(false);
const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState<number | null>(null);

useEffect(() => {
if (!isDragging) return;
Expand All @@ -225,6 +255,7 @@ function PlaybackCursor({

const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const contentWidth = Math.max(rect.width - sidebarWidth, 1);

// Allow dragging outside to 0 or max, but clamp the value
const relativeMs = pixelsToValue(clickX);
Expand All @@ -243,11 +274,51 @@ function PlaybackCursor({
absoluteMs = nearbyKeyframe.time;
}

setDragPreviewTimeMs(absoluteMs);

const visibleMs = range.end - range.start;
if (onRangeChange && visibleMs > 0 && videoDurationMs > visibleMs) {
const msPerPixel = visibleMs / contentWidth;
const overflowLeftPx = Math.max(0, -clickX);
const overflowRightPx = Math.max(0, clickX - contentWidth);

if (overflowLeftPx > 0 && range.start > 0) {
const shiftMs = overflowLeftPx * msPerPixel;
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start - shiftMs,
end: previous.end - shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
} else if (overflowRightPx > 0 && range.end < videoDurationMs) {
const shiftMs = overflowRightPx * msPerPixel;
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start + shiftMs,
end: previous.end + shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
}
}

onSeek(absoluteMs / 1000);
};

const handleMouseUp = () => {
setIsDragging(false);
setDragPreviewTimeMs(null);
document.body.style.cursor = "";
};

Expand All @@ -263,6 +334,7 @@ function PlaybackCursor({
}, [
isDragging,
onSeek,
onRangeChange,
timelineRef,
sidebarWidth,
range.start,
Expand All @@ -272,11 +344,14 @@ function PlaybackCursor({
keyframes,
]);

if (videoDurationMs <= 0 || currentTimeMs < 0) {
const displayTimeMs =
isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs;

if (videoDurationMs <= 0 || displayTimeMs < 0) {
return null;
}

const clampedTime = Math.min(currentTimeMs, videoDurationMs);
const clampedTime = Math.min(displayTimeMs, videoDurationMs);

if (clampedTime < range.start || clampedTime > range.end) {
return null;
Expand All @@ -299,6 +374,7 @@ function PlaybackCursor({
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent timeline click
setDragPreviewTimeMs(currentTimeMs);
setIsDragging(true);
}}
>
Expand Down Expand Up @@ -444,6 +520,7 @@ function Timeline({
videoDurationMs,
currentTimeMs,
onSeek,
onRangeChange,
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
Expand All @@ -458,6 +535,7 @@ function Timeline({
videoDurationMs: number;
currentTimeMs: number;
onSeek?: (time: number) => void;
onRangeChange?: (updater: (previous: Range) => Range) => void;
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
Expand Down Expand Up @@ -514,6 +592,46 @@ function Timeline({
],
);

const handleTimelineWheel = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
return;
}

const visibleMs = range.end - range.start;
if (visibleMs <= 0 || videoDurationMs <= visibleMs) {
return;
}

const dominantDelta =
Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (dominantDelta === 0) {
return;
}

event.preventDefault();

const pageWidthPx = Math.max(event.currentTarget.clientWidth - sidebarWidth, 1);
const normalizedDeltaPx = normalizeWheelDelta(dominantDelta, event.deltaMode, pageWidthPx);
const shiftMs = pixelsToValue(normalizedDeltaPx);

onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start + shiftMs,
end: previous.end + shiftMs,
},
videoDurationMs,
);

return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
},
[onRangeChange, videoDurationMs, range.end, range.start, sidebarWidth, pixelsToValue],
);

const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
Expand All @@ -525,13 +643,15 @@ function Timeline({
style={style}
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
onClick={handleTimelineClick}
onWheel={handleTimelineWheel}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor
currentTimeMs={currentTimeMs}
videoDurationMs={videoDurationMs}
onSeek={onSeek}
onRangeChange={onRangeChange}
timelineRef={localTimelineRef}
keyframes={keyframes}
/>
Expand Down Expand Up @@ -657,17 +777,15 @@ export default function TimelineEditor({
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
pan: "Scroll",
zoom: "Ctrl + Scroll",
});
const timelineContainerRef = useRef<HTMLDivElement>(null);
const { shortcuts: keyShortcuts, isMac } = useShortcuts();

useEffect(() => {
formatShortcut(["shift", "mod", "Scroll"]).then((pan) => {
formatShortcut(["mod", "Scroll"]).then((zoom) => {
setScrollLabels({ pan, zoom });
});
formatShortcut(["mod", "Scroll"]).then((zoom) => {
setScrollLabels({ pan: "Scroll", zoom });
});
}, []);

Expand Down Expand Up @@ -1351,6 +1469,7 @@ export default function TimelineEditor({
videoDurationMs={totalMs}
currentTimeMs={currentTimeMs}
onSeek={onSeek}
onRangeChange={setRange}
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
Expand Down
Loading