From d9177b4a44a9ed48447172937ca2c5fdb5f132a7 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Thu, 12 Feb 2026 22:53:19 -0800 Subject: [PATCH] more timeline ux qol improvements: --- src/components/video-editor/timeline/Item.tsx | 91 ++++++++------ src/components/video-editor/timeline/Row.tsx | 23 +++- .../video-editor/timeline/TimelineEditor.tsx | 20 +++- .../video-editor/timeline/TimelineWrapper.tsx | 111 +++++++++++++++++- 4 files changed, 201 insertions(+), 44 deletions(-) diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index b073786..ed5fc8b 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useItem } from "dnd-timeline"; import type { Span } from "dnd-timeline"; import { cn } from "@/lib/utils"; @@ -25,6 +26,16 @@ const ZOOM_LABELS: Record = { 6: "5×", }; +function formatMs(ms: number): string { + const totalSeconds = ms / 1000; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes > 0) { + return `${minutes}:${seconds.toFixed(1).padStart(4, '0')}`; + } + return `${seconds.toFixed(1)}s`; +} + export default function Item({ id, span, @@ -43,19 +54,24 @@ export default function Item({ const isZoom = variant === 'zoom'; const isTrim = variant === 'trim'; - - const glassClass = isZoom - ? glassStyles.glassGreen - : isTrim - ? glassStyles.glassRed + + const glassClass = isZoom + ? glassStyles.glassGreen + : isTrim + ? glassStyles.glassRed : glassStyles.glassYellow; - - const endCapColor = isZoom - ? '#21916A' - : isTrim - ? '#ef4444' + + const endCapColor = isZoom + ? '#21916A' + : isTrim + ? '#ef4444' : '#B4A046'; + const timeLabel = useMemo( + () => `${formatMs(span.start)} – ${formatMs(span.end)}`, + [span.start, span.end], + ); + return (
{/* Content */} -
- {isZoom ? ( - <> - - - {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`} - - - ) : isTrim ? ( - <> - - - Trim - - - ) : ( - <> - - - {children} - - - )} +
+
+ {isZoom ? ( + <> + + + {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`} + + + ) : isTrim ? ( + <> + + + Trim + + + ) : ( + <> + + + {children} + + + )} +
+ + {timeLabel} +
diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 4027021..d6cc1e5 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -3,19 +3,36 @@ import type { RowDefinition } from "dnd-timeline"; interface RowProps extends RowDefinition { children: React.ReactNode; + label?: string; + hint?: string; + isEmpty?: boolean; + labelColor?: string; } -export default function Row({ id, children }: RowProps) { +export default function Row({ id, children, label, hint, isEmpty, labelColor = '#666' }: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); return (
+ {label && ( +
+ {label} +
+ )} + {isEmpty && hint && ( +
+ {hint} +
+ )}
{children}
); -} \ No newline at end of file +} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index c1d1500..9b091ef 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -155,6 +155,14 @@ function formatTimeLabel(milliseconds: number, intervalMs: number) { return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`; } +function formatPlayheadTime(ms: number): string { + const s = ms / 1000; + const min = Math.floor(s / 60); + const sec = s % 60; + if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, '0')}`; + return `${sec.toFixed(1)}s`; +} + function PlaybackCursor({ currentTimeMs, videoDurationMs, @@ -252,6 +260,11 @@ function PlaybackCursor({ >
+ {isDragging && ( +
+ {formatPlayheadTime(clampedTime)} +
+ )} ); @@ -451,7 +464,7 @@ function Timeline({ keyframes={keyframes} /> - + {zoomItems.map((item) => ( - + {trimItems.map((item) => ( - + {annotationItems.map((item) => ( diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx index c5ff3f9..35685b3 100644 --- a/src/components/video-editor/timeline/TimelineWrapper.tsx +++ b/src/components/video-editor/timeline/TimelineWrapper.tsx @@ -1,7 +1,15 @@ -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import type { Dispatch, ReactNode, SetStateAction } from "react"; import { TimelineContext } from "dnd-timeline"; -import type { DragEndEvent, Range, ResizeEndEvent, Span } from "dnd-timeline"; +import type { + DragEndEvent, + DragMoveEvent, + DragStartEvent, + Range, + ResizeEndEvent, + ResizeMoveEvent, + Span, +} from "dnd-timeline"; interface TimelineWrapperProps { children: ReactNode; @@ -167,6 +175,88 @@ export default function TimelineWrapper({ [clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange] ); + // Drag/resize tooltip (direct DOM updates, no re-renders) + const tooltipRef = useRef(null); + + const formatTooltipMs = (ms: number) => { + const s = ms / 1000; + const min = Math.floor(s / 60); + const sec = s % 60; + return min > 0 + ? `${min}:${sec.toFixed(1).padStart(4, '0')}` + : `${sec.toFixed(1)}s`; + }; + + const showTooltip = useCallback( + (span: { start: number; end: number } | null, screenX?: number) => { + const el = tooltipRef.current; + if (!el) return; + if (!span) { + el.style.opacity = '0'; + return; + } + el.textContent = `${formatTooltipMs(span.start)} – ${formatTooltipMs(span.end)}`; + el.style.opacity = '1'; + if (screenX !== undefined) { + const parent = el.parentElement; + if (parent) { + const rect = parent.getBoundingClientRect(); + const x = Math.max(0, Math.min(screenX - rect.left, rect.width - 100)); + el.style.left = `${x}px`; + } + } + }, + [], + ); + + const onDragStart = useCallback( + (event: DragStartEvent) => { + const span = event.active.data.current.getSpanFromDragEvent?.(event); + if (span) showTooltip(span); + }, + [showTooltip], + ); + + const onDragMove = useCallback( + (event: DragMoveEvent) => { + const span = event.active.data.current.getSpanFromDragEvent?.(event); + const screenX = event.activatorEvent && 'clientX' in event.activatorEvent + ? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0) + : undefined; + if (span) showTooltip(span, screenX); + }, + [showTooltip], + ); + + const onResizeMove = useCallback( + (event: ResizeMoveEvent) => { + const span = event.active.data.current.getSpanFromResizeEvent?.(event); + const screenX = event.activatorEvent && 'clientX' in event.activatorEvent + ? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0) + : undefined; + if (span) showTooltip(span, screenX); + }, + [showTooltip], + ); + + const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]); + + const onResizeEndWithTooltip = useCallback( + (event: ResizeEndEvent) => { + hideTooltip(); + onResizeEnd(event); + }, + [hideTooltip, onResizeEnd], + ); + + const onDragEndWithTooltip = useCallback( + (event: DragEndEvent) => { + hideTooltip(); + onDragEnd(event); + }, + [hideTooltip, onDragEnd], + ); + const handleRangeChange = useCallback( (updater: (previous: Range) => Range) => { onRangeChange((prev) => { @@ -197,11 +287,22 @@ export default function TimelineWrapper({ - {children} +
+ {children} + {/* Floating tooltip shown during drag/resize */} +
+
); } \ No newline at end of file