UX improved for timeline

This commit is contained in:
Siddharth
2026-02-12 22:33:41 -08:00
parent 8e94dcbc2c
commit 4d7e2a2d85
4 changed files with 94 additions and 30 deletions
@@ -65,14 +65,14 @@ export default function Item({
onPointerDownCapture={() => onSelect?.()}
className="group"
>
<div style={itemContentStyle}>
<div style={{ ...itemContentStyle, minWidth: 24 }}>
<div
className={cn(
glassClass,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected
)}
style={{ height: 40, color: '#fff' }}
style={{ height: 40, color: '#fff', minWidth: 24 }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
@@ -83,7 +83,7 @@
width: 4px;
pointer-events: none;
z-index: 2;
opacity: 0;
opacity: 0.45;
transition: opacity 0.2s, width 0.2s;
}
@@ -597,12 +597,20 @@ export default function TimelineEditor({
setRange(createInitialRange(totalMs));
}, [totalMs]);
// Normalize regions only when timeline bounds change (not on every region edit).
// Using refs to read current regions avoids a dependency-loop that re-fires
// this effect on every drag/resize and races with dnd-timeline's internal state.
const zoomRegionsRef = useRef(zoomRegions);
const trimRegionsRef = useRef(trimRegions);
zoomRegionsRef.current = zoomRegions;
trimRegionsRef.current = trimRegions;
useEffect(() => {
if (totalMs === 0 || safeMinDurationMs <= 0) {
return;
}
zoomRegions.forEach((region) => {
zoomRegionsRef.current.forEach((region) => {
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
const minEnd = clampedStart + safeMinDurationMs;
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
@@ -614,7 +622,7 @@ export default function TimelineEditor({
}
});
trimRegions.forEach((region) => {
trimRegionsRef.current.forEach((region) => {
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
const minEnd = clampedStart + safeMinDurationMs;
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
@@ -625,7 +633,8 @@ export default function TimelineEditor({
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
}, [zoomRegions, trimRegions, annotationRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
// Only re-run when the timeline scale changes, not on every region edit
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
// Determine which row the item belongs to
@@ -641,12 +650,8 @@ export default function TimelineEditor({
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
return regions.some((region) => {
if (region.id === excludeId) return false;
const gapBefore = newSpan.start - region.endMs;
const gapAfter = region.startMs - newSpan.end;
// Snap if gap is 2ms or less
if (gapBefore > 0 && gapBefore <= 2) return true;
if (gapAfter > 0 && gapAfter <= 2) return true;
return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs);
// True overlap: regions actually intersect (not just adjacent)
return newSpan.end > region.startMs && newSpan.start < region.endMs;
});
};
@@ -661,12 +666,19 @@ export default function TimelineEditor({
return false;
}, [zoomRegions, trimRegions, annotationRegions]);
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
// is always wide enough to grab and resize comfortably.
const defaultRegionDurationMs = useMemo(
() => Math.max(1000, Math.round(totalMs * 0.05)),
[totalMs],
);
const handleAddZoom = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
return;
}
const defaultDuration = Math.min(1000, totalMs);
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
@@ -687,16 +699,16 @@ export default function TimelineEditor({
return;
}
const actualDuration = Math.min(1000, gapToNext);
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onZoomAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded]);
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
const handleAddTrim = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
return;
}
const defaultDuration = Math.min(1000, totalMs);
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
@@ -717,16 +729,16 @@ export default function TimelineEditor({
return;
}
const actualDuration = Math.min(1000, gapToNext);
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded]);
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
const handleAddAnnotation = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
}
const defaultDuration = Math.min(1000, totalMs);
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
@@ -736,7 +748,7 @@ export default function TimelineEditor({
const endPos = Math.min(startPos + defaultDuration, totalMs);
onAnnotationAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded]);
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -850,6 +862,13 @@ export default function TimelineEditor({
return [...zooms, ...trims, ...annotations];
}, [zoomRegions, trimRegions, annotationRegions]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
return [...zooms, ...trims];
}, [zoomRegions, trimRegions]);
const handleItemSpanChange = useCallback((id: string, span: Span) => {
// Check if it's a zoom or trim item
if (zoomRegions.some(r => r.id === id)) {
@@ -961,6 +980,7 @@ export default function TimelineEditor({
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
gridSizeMs={timelineScale.gridMs}
onItemSpanChange={handleItemSpanChange}
allRegionSpans={allRegionSpans}
>
<KeyframeMarkers
keyframes={keyframes}
@@ -13,6 +13,7 @@ interface TimelineWrapperProps {
minVisibleRangeMs: number;
gridSizeMs: number;
onItemSpanChange: (id: string, span: Span) => void;
allRegionSpans?: { id: string; start: number; end: number }[];
}
export default function TimelineWrapper({
@@ -25,6 +26,7 @@ export default function TimelineWrapper({
minVisibleRangeMs,
gridSizeMs: _gridSizeMs,
onItemSpanChange,
allRegionSpans = [],
}: TimelineWrapperProps) {
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
@@ -84,25 +86,63 @@ export default function TimelineWrapper({
[minVisibleRangeMs, totalMs],
);
// When a span overlaps neighbours, clamp it to the nearest boundary
const clampToNeighbours = useCallback(
(span: Span, activeItemId: string): Span => {
const siblings = allRegionSpans.filter((r) => r.id !== activeItemId);
let { start, end } = span;
for (const r of siblings) {
// Span's right edge crossed into a region to the right
if (end > r.start && start < r.start) {
end = r.start;
}
// Span's left edge crossed into a region to the left
if (start < r.end && end > r.end) {
start = r.end;
}
}
// Ensure minimum duration after clamping
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
if (end - start < minDur) {
// Try extending in the direction that has room
if (end + minDur - (end - start) <= totalMs) {
end = start + minDur;
} else {
start = end - minDur;
}
}
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
},
[allRegionSpans, minItemDurationMs, totalMs],
);
const onResizeEnd = useCallback(
(event: ResizeEndEvent) => {
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
if (!updatedSpan) return;
const activeItemId = event.active.id as string;
const clampedSpan = clampSpanToBounds(updatedSpan);
let clampedSpan = clampSpanToBounds(updatedSpan);
if (clampedSpan.end - clampedSpan.start < Math.min(minItemDurationMs, totalMs || minItemDurationMs)) {
return;
}
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
return;
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
// If still overlapping after clamping, fall back to original position
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
}
onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs]
[clampSpanToBounds, clampToNeighbours, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs]
);
const onDragEnd = useCallback(
@@ -110,17 +150,21 @@ export default function TimelineWrapper({
const activeRowId = event.over?.id as string;
const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event);
if (!updatedSpan || !activeRowId) return;
const activeItemId = event.active.id as string;
const clampedSpan = clampSpanToBounds(updatedSpan);
let clampedSpan = clampSpanToBounds(updatedSpan);
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
return;
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
}
onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, hasOverlap, onItemSpanChange]
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange]
);
const handleRangeChange = useCallback(