Merge pull request #515 from AbhinRustagi/feature/timeline-handle-snap

Add Timeline Snap Guides
This commit is contained in:
Sid
2026-05-30 14:06:41 -07:00
committed by GitHub
2 changed files with 268 additions and 14 deletions
@@ -1517,7 +1517,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 }));
@@ -1525,6 +1528,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
@@ -1699,6 +1716,9 @@ export default function TimelineEditor({
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
onItemSpanChange={handleItemSpanChange}
allRegionSpans={allRegionSpans}
softSnapSpans={softSnapSpans}
currentTimeMs={currentTimeMs}
keyframeTimesMs={keyframeTimesMs}
>
<KeyframeMarkers
keyframes={keyframes}
@@ -7,9 +7,9 @@ import type {
ResizeMoveEvent,
Span,
} from "dnd-timeline";
import { TimelineContext } from "dnd-timeline";
import { TimelineContext, useTimelineContext } from "dnd-timeline";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useCallback, useRef } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
interface TimelineWrapperProps {
children: ReactNode;
@@ -21,9 +21,75 @@ interface TimelineWrapperProps {
minVisibleRangeMs: number;
gridSizeMs?: number;
onItemSpanChange: (id: string, span: Span) => 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<SnapGuideHandle>((_, ref) => {
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
const elRef = useRef<HTMLDivElement>(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 (
<div
ref={elRef}
className="absolute top-0 bottom-0 w-[2px] bg-[#fbbf24] shadow-[0_0_10px_rgba(251,191,36,0.85),0_0_2px_rgba(251,191,36,1)] pointer-events-none z-[55]"
style={{ opacity: 0, transition: "opacity 0.08s" }}
>
<div
className="absolute -top-[1px] left-1/2 -translate-x-1/2 w-0 h-0"
style={{
borderLeft: "4px solid transparent",
borderRight: "4px solid transparent",
borderTop: "6px solid #fbbf24",
}}
/>
<div
className="absolute -bottom-[1px] left-1/2 -translate-x-1/2 w-0 h-0"
style={{
borderLeft: "4px solid transparent",
borderRight: "4px solid transparent",
borderBottom: "6px solid #fbbf24",
}}
/>
</div>
);
});
SnapGuide.displayName = "SnapGuide";
export default function TimelineWrapper({
children,
range,
@@ -35,6 +101,9 @@ export default function TimelineWrapper({
gridSizeMs: _gridSizeMs,
onItemSpanChange,
allRegionSpans = [],
softSnapSpans = [],
currentTimeMs,
keyframeTimesMs = [],
}: TimelineWrapperProps) {
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
@@ -127,6 +196,142 @@ export default function TimelineWrapper({
[allRegionSpans, minItemDurationMs, totalMs],
);
const snapGuideRef = useRef<SnapGuideHandle>(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<number>();
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).
// Returns null when the deltas are equal — including the common clamped
// case where both are 0 — because we can't tell which handle the user
// grabbed, and guessing wrong would snap the other edge.
const inferResizeMode = useCallback(
(activeItemId: string, span: Span): "resize-left" | "resize-right" | null => {
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);
if (startDelta === endDelta) return null;
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 +340,11 @@ export default function TimelineWrapper({
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
const mode = inferResizeMode(activeItemId, clampedSpan);
if (mode !== null) {
clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, mode).span;
}
const effectiveMinDuration =
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
@@ -156,8 +366,10 @@ export default function TimelineWrapper({
clampSpanToBounds,
clampToNeighbours,
hasOverlap,
inferResizeMode,
minItemDurationMs,
onItemSpanChange,
snapSpanToTargets,
totalMs,
],
);
@@ -171,6 +383,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 +395,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 +440,63 @@ 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 } =
mode !== null
? snapSpanToTargets(clamped, activeItemId, mode)
: { span: clamped, snapPoint: null };
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 +538,7 @@ export default function TimelineWrapper({
>
<div className="relative">
{children}
<SnapGuide ref={snapGuideRef} />
{/* Floating tooltip shown during drag/resize */}
<div
ref={tooltipRef}