more timeline ux qol improvements:

This commit is contained in:
Siddharth
2026-02-12 22:53:19 -08:00
parent 4d7e2a2d85
commit d9177b4a44
4 changed files with 201 additions and 44 deletions
+58 -33
View File
@@ -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<number, string> = {
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 (
<div
ref={setNodeRef}
@@ -89,29 +105,38 @@ export default function Item({
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex items-center gap-1.5 text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
Trim
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{children}
</span>
</>
)}
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="flex items-center gap-1.5">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
Trim
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{children}
</span>
</>
)}
</div>
<span
className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${
isSelected ? 'opacity-60' : 'opacity-0 group-hover:opacity-40'
}`}
>
{timeLabel}
</span>
</div>
</div>
</div>
+20 -3
View File
@@ -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 (
<div
className="border-b border-[#18181b] bg-[#18181b]"
className="border-b border-[#18181b] bg-[#18181b] relative"
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
>
{label && (
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
style={{ color: labelColor, writingMode: 'horizontal-tb' }}
>
{label}
</div>
)}
{isEmpty && hint && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
</div>
)}
<div ref={setNodeRef} style={rowStyle}>
{children}
</div>
</div>
);
}
}
@@ -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({
>
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
</div>
{isDragging && (
<div className="absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg pointer-events-none">
{formatPlayheadTime(clampedTime)}
</div>
)}
</div>
</div>
);
@@ -451,7 +464,7 @@ function Timeline({
keyframes={keyframes}
/>
<Row id={ZOOM_ROW_ID}>
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
{zoomItems.map((item) => (
<Item
id={item.id}
@@ -468,7 +481,7 @@ function Timeline({
))}
</Row>
<Row id={TRIM_ROW_ID}>
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
{trimItems.map((item) => (
<Item
id={item.id}
@@ -484,7 +497,7 @@ function Timeline({
))}
</Row>
<Row id={ANNOTATION_ROW_ID}>
<Row id={ANNOTATION_ROW_ID} isEmpty={annotationItems.length === 0} hint="Press A to add annotation">
{annotationItems.map((item) => (
<Item
id={item.id}
@@ -1003,6 +1016,7 @@ export default function TimelineEditor({
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
keyframes={keyframes}
/>
</TimelineWrapper>
</div>
@@ -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<HTMLDivElement>(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({
<TimelineContext
range={range}
onRangeChanged={handleRangeChange}
onResizeEnd={onResizeEnd}
onDragEnd={onDragEnd}
onResizeEnd={onResizeEndWithTooltip}
onResizeMove={onResizeMove}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEndWithTooltip}
autoScroll={{ enabled: false }}
>
{children}
<div className="relative">
{children}
{/* Floating tooltip shown during drag/resize */}
<div
ref={tooltipRef}
className="absolute top-1 pointer-events-none z-[60] px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg"
style={{ opacity: 0, transition: 'opacity 0.1s' }}
/>
</div>
</TimelineContext>
);
}