From 3aad05abd23d045b1caeb9ecacb0f29cbc82c5be Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 02:38:25 +0530 Subject: [PATCH] feat: add timeline snap guides --- .../video-editor/timeline/TimelineEditor.tsx | 22 +- .../video-editor/timeline/TimelineWrapper.tsx | 234 +++++++++++++++++- 2 files changed, 242 insertions(+), 14 deletions(-) diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 6fe3474d8..6c0ed93e2 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1393,7 +1393,10 @@ export default function TimelineEditor({ return [...zooms, ...trims, ...annotations, ...blurs, ...speeds]; }, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]); - // Flat list of all non-annotation region spans for neighbour-clamping during drag/resize + // Spans that participate in overlap resolution (clampToNeighbours). + // Excludes annotation/blur deliberately — those are allowed to overlap and + // must NOT act as hard constraints when a zoom/trim/speed drag is being + // resolved. 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 })); @@ -1401,6 +1404,20 @@ export default function TimelineEditor({ return [...zooms, ...trims, ...speeds]; }, [zoomRegions, trimRegions, speedRegions]); + // Additional snap targets that are NOT clamping constraints. Their edges + // pull during snap, but they don't push anyone away. + const softSnapSpans = useMemo(() => { + const annotations = annotationRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + })); + const blurs = blurRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); + return [...annotations, ...blurs]; + }, [annotationRegions, blurRegions]); + + const keyframeTimesMs = useMemo(() => keyframes.map((kf) => kf.time), [keyframes]); + const handleItemSpanChange = useCallback( (id: string, span: Span) => { // Check if it's a zoom, trim, speed, or annotation item @@ -1571,6 +1588,9 @@ export default function TimelineEditor({ minVisibleRangeMs={timelineScale.minVisibleRangeMs} onItemSpanChange={handleItemSpanChange} allRegionSpans={allRegionSpans} + softSnapSpans={softSnapSpans} + currentTimeMs={currentTimeMs} + keyframeTimesMs={keyframeTimesMs} > void; + // Spans that act as hard overlap constraints (zoom/trim/speed). Used by + // clampToNeighbours AND as snap targets. allRegionSpans?: { id: string; start: number; end: number }[]; + // Spans that act ONLY as snap targets (annotation/blur). They never push + // other items away during overlap resolution. + softSnapSpans?: { id: string; start: number; end: number }[]; + currentTimeMs?: number; + keyframeTimesMs?: number[]; } +interface SnapGuideHandle { + showAt: (timeMs: number) => void; + hide: () => void; +} + +// Lives inside TimelineContext so it can read valueToPixels. Updates DOM +// directly via an imperative handle — same pattern as the drag tooltip — to +// avoid re-rendering the timeline on every pointer move. +const SnapGuide = forwardRef((_, ref) => { + const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext(); + const elRef = useRef(null); + const sideProperty = direction === "rtl" ? "right" : "left"; + + useImperativeHandle( + ref, + () => ({ + showAt(timeMs: number) { + const el = elRef.current; + if (!el) return; + const offset = valueToPixels(timeMs - range.start) + sidebarWidth; + el.style[sideProperty] = `${offset}px`; + el.style.opacity = "1"; + }, + hide() { + const el = elRef.current; + if (!el) return; + el.style.opacity = "0"; + }, + }), + [range.start, sidebarWidth, sideProperty, valueToPixels], + ); + + return ( +
+ ); +}); +SnapGuide.displayName = "SnapGuide"; + export default function TimelineWrapper({ children, range, @@ -35,6 +84,9 @@ export default function TimelineWrapper({ gridSizeMs: _gridSizeMs, onItemSpanChange, allRegionSpans = [], + softSnapSpans = [], + currentTimeMs, + keyframeTimesMs = [], }: TimelineWrapperProps) { const totalMs = Math.max(0, Math.round(videoDuration * 1000)); @@ -127,6 +179,138 @@ export default function TimelineWrapper({ [allRegionSpans, minItemDurationMs, totalMs], ); + const snapGuideRef = useRef(null); + + // Pull the active span's edges to nearby region boundaries, timeline bounds, + // the playhead, and keyframes. Threshold scales with zoom (~1% of visible + // range, min 50ms) so snap feels right at any zoom level. + // Returns the snapped span plus the actual snap target used (for guide rendering). + const snapSpanToTargets = useCallback( + ( + span: Span, + activeItemId: string, + mode: "drag" | "resize-left" | "resize-right", + ): { span: Span; snapPoint: number | null } => { + if (totalMs === 0) return { span, snapPoint: null }; + + const visibleMs = Math.max(range.end - range.start, 1); + const thresholdMs = Math.max(50, Math.round(visibleMs / 100)); + + const targetSet = new Set(); + targetSet.add(0); + targetSet.add(totalMs); + for (const r of allRegionSpans) { + if (r.id === activeItemId) continue; + targetSet.add(r.start); + targetSet.add(r.end); + } + for (const r of softSnapSpans) { + if (r.id === activeItemId) continue; + targetSet.add(r.start); + targetSet.add(r.end); + } + if (currentTimeMs !== undefined) targetSet.add(currentTimeMs); + for (const kf of keyframeTimesMs) targetSet.add(kf); + const targets = Array.from(targetSet); + + const findNearest = (value: number): number | null => { + let best: number | null = null; + let bestDistance = thresholdMs; + for (const target of targets) { + const distance = Math.abs(target - value); + if (distance <= bestDistance) { + best = target; + bestDistance = distance; + } + } + return best; + }; + + if (mode === "resize-left") { + const snap = findNearest(span.start); + if (snap === null || span.end - snap < minItemDurationMs) { + return { span, snapPoint: null }; + } + return { span: { start: snap, end: span.end }, snapPoint: snap }; + } + + if (mode === "resize-right") { + const snap = findNearest(span.end); + if (snap === null || snap - span.start < minItemDurationMs) { + return { span, snapPoint: null }; + } + return { span: { start: span.start, end: snap }, snapPoint: snap }; + } + + // Drag: preserve duration; snap whichever edge is closer to a target. + const startSnap = findNearest(span.start); + const endSnap = findNearest(span.end); + const startDelta = startSnap !== null ? Math.abs(startSnap - span.start) : Infinity; + const endDelta = endSnap !== null ? Math.abs(endSnap - span.end) : Infinity; + + if (startDelta === Infinity && endDelta === Infinity) { + return { span, snapPoint: null }; + } + + const duration = span.end - span.start; + if (startDelta <= endDelta && startSnap !== null) { + return { + span: { start: startSnap, end: startSnap + duration }, + snapPoint: startSnap, + }; + } + if (endSnap !== null) { + return { + span: { start: endSnap - duration, end: endSnap }, + snapPoint: endSnap, + }; + } + return { span, snapPoint: null }; + }, + [ + allRegionSpans, + softSnapSpans, + currentTimeMs, + keyframeTimesMs, + minItemDurationMs, + range.end, + range.start, + totalMs, + ], + ); + + // dnd-timeline's resize event doesn't expose direction. Compare the live + // span to the committed one (committed spans only update on commit, so + // during a single resize they still reflect the pre-resize state). + const inferResizeMode = useCallback( + (activeItemId: string, span: Span): "resize-left" | "resize-right" => { + const old = + allRegionSpans.find((r) => r.id === activeItemId) ?? + softSnapSpans.find((r) => r.id === activeItemId); + if (!old) return "resize-right"; + const startDelta = Math.abs(old.start - span.start); + const endDelta = Math.abs(old.end - span.end); + return startDelta >= endDelta ? "resize-left" : "resize-right"; + }, + [allRegionSpans, softSnapSpans], + ); + + const updateSnapGuide = useCallback( + (snapPoint: number | null) => { + if (snapPoint === null) { + snapGuideRef.current?.hide(); + return; + } + // Hide the amber guide when it would coincide with the green playhead. + if (currentTimeMs !== undefined && Math.abs(snapPoint - currentTimeMs) < 1) { + snapGuideRef.current?.hide(); + return; + } + snapGuideRef.current?.showAt(snapPoint); + }, + [currentTimeMs], + ); + const onResizeEnd = useCallback( (event: ResizeEndEvent) => { const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event); @@ -135,6 +319,9 @@ export default function TimelineWrapper({ const activeItemId = event.active.id as string; let clampedSpan = clampSpanToBounds(updatedSpan); + const mode = inferResizeMode(activeItemId, clampedSpan); + clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, mode).span; + const effectiveMinDuration = totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs; if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) { @@ -156,8 +343,10 @@ export default function TimelineWrapper({ clampSpanToBounds, clampToNeighbours, hasOverlap, + inferResizeMode, minItemDurationMs, onItemSpanChange, + snapSpanToTargets, totalMs, ], ); @@ -171,6 +360,8 @@ export default function TimelineWrapper({ const activeItemId = event.active.id as string; let clampedSpan = clampSpanToBounds(updatedSpan); + clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, "drag").span; + // Clamp to neighbour boundaries instead of rejecting if (hasOverlap(clampedSpan, activeItemId)) { clampedSpan = clampToNeighbours(clampedSpan, activeItemId); @@ -181,7 +372,7 @@ export default function TimelineWrapper({ onItemSpanChange(activeItemId, clampedSpan); }, - [clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange], + [clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange, snapSpanToTargets], ); // Drag/resize tooltip (direct DOM updates, no re-renders) @@ -226,44 +417,60 @@ export default function TimelineWrapper({ const onDragMove = useCallback( (event: DragMoveEvent) => { - const span = event.active.data.current.getSpanFromDragEvent?.(event); + const rawSpan = event.active.data.current.getSpanFromDragEvent?.(event); + if (!rawSpan) return; + const activeItemId = event.active.id as string; + const clamped = totalMs > 0 ? clampSpanToBounds(rawSpan) : rawSpan; + const { span, snapPoint } = snapSpanToTargets(clamped, activeItemId, "drag"); + updateSnapGuide(snapPoint); const screenX = event.activatorEvent && "clientX" in event.activatorEvent ? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0) : undefined; - if (span) showTooltip(span, screenX); + showTooltip(span, screenX); }, - [showTooltip], + [clampSpanToBounds, showTooltip, snapSpanToTargets, totalMs, updateSnapGuide], ); const onResizeMove = useCallback( (event: ResizeMoveEvent) => { - const span = event.active.data.current.getSpanFromResizeEvent?.(event); + const rawSpan = event.active.data.current.getSpanFromResizeEvent?.(event); + if (!rawSpan) return; + const activeItemId = event.active.id as string; + const clamped = totalMs > 0 ? clampSpanToBounds(rawSpan) : rawSpan; + const mode = inferResizeMode(activeItemId, clamped); + const { span, snapPoint } = snapSpanToTargets(clamped, activeItemId, mode); + updateSnapGuide(snapPoint); const screenX = event.activatorEvent && "clientX" in event.activatorEvent ? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0) : undefined; - if (span) showTooltip(span, screenX); + showTooltip(span, screenX); }, - [showTooltip], + [clampSpanToBounds, inferResizeMode, showTooltip, snapSpanToTargets, totalMs, updateSnapGuide], ); const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]); + const hideOverlays = useCallback(() => { + hideTooltip(); + snapGuideRef.current?.hide(); + }, [hideTooltip]); + const onResizeEndWithTooltip = useCallback( (event: ResizeEndEvent) => { - hideTooltip(); + hideOverlays(); onResizeEnd(event); }, - [hideTooltip, onResizeEnd], + [hideOverlays, onResizeEnd], ); const onDragEndWithTooltip = useCallback( (event: DragEndEvent) => { - hideTooltip(); + hideOverlays(); onDragEnd(event); }, - [hideTooltip, onDragEnd], + [hideOverlays, onDragEnd], ); const handleRangeChange = useCallback( @@ -305,6 +512,7 @@ export default function TimelineWrapper({ >
{children} + {/* Floating tooltip shown during drag/resize */}