Skip to content
This repository was archived by the owner on Jun 17, 2026. It is now read-only.

Commit 3aad05a

Browse files
committed
feat: add timeline snap guides
1 parent 884021c commit 3aad05a

2 files changed

Lines changed: 242 additions & 14 deletions

File tree

src/components/video-editor/timeline/TimelineEditor.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1393,14 +1393,31 @@ export default function TimelineEditor({
13931393
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
13941394
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
13951395

1396-
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
1396+
// Spans that participate in overlap resolution (clampToNeighbours).
1397+
// Excludes annotation/blur deliberately — those are allowed to overlap and
1398+
// must NOT act as hard constraints when a zoom/trim/speed drag is being
1399+
// resolved.
13971400
const allRegionSpans = useMemo(() => {
13981401
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
13991402
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
14001403
const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
14011404
return [...zooms, ...trims, ...speeds];
14021405
}, [zoomRegions, trimRegions, speedRegions]);
14031406

1407+
// Additional snap targets that are NOT clamping constraints. Their edges
1408+
// pull during snap, but they don't push anyone away.
1409+
const softSnapSpans = useMemo(() => {
1410+
const annotations = annotationRegions.map((r) => ({
1411+
id: r.id,
1412+
start: r.startMs,
1413+
end: r.endMs,
1414+
}));
1415+
const blurs = blurRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
1416+
return [...annotations, ...blurs];
1417+
}, [annotationRegions, blurRegions]);
1418+
1419+
const keyframeTimesMs = useMemo(() => keyframes.map((kf) => kf.time), [keyframes]);
1420+
14041421
const handleItemSpanChange = useCallback(
14051422
(id: string, span: Span) => {
14061423
// Check if it's a zoom, trim, speed, or annotation item
@@ -1571,6 +1588,9 @@ export default function TimelineEditor({
15711588
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
15721589
onItemSpanChange={handleItemSpanChange}
15731590
allRegionSpans={allRegionSpans}
1591+
softSnapSpans={softSnapSpans}
1592+
currentTimeMs={currentTimeMs}
1593+
keyframeTimesMs={keyframeTimesMs}
15741594
>
15751595
<KeyframeMarkers
15761596
keyframes={keyframes}

src/components/video-editor/timeline/TimelineWrapper.tsx

Lines changed: 221 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import type {
77
ResizeMoveEvent,
88
Span,
99
} from "dnd-timeline";
10-
import { TimelineContext } from "dnd-timeline";
10+
import { TimelineContext, useTimelineContext } from "dnd-timeline";
1111
import type { Dispatch, ReactNode, SetStateAction } from "react";
12-
import { useCallback, useRef } from "react";
12+
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
1313

1414
interface TimelineWrapperProps {
1515
children: ReactNode;
@@ -21,9 +21,58 @@ interface TimelineWrapperProps {
2121
minVisibleRangeMs: number;
2222
gridSizeMs?: number;
2323
onItemSpanChange: (id: string, span: Span) => void;
24+
// Spans that act as hard overlap constraints (zoom/trim/speed). Used by
25+
// clampToNeighbours AND as snap targets.
2426
allRegionSpans?: { id: string; start: number; end: number }[];
27+
// Spans that act ONLY as snap targets (annotation/blur). They never push
28+
// other items away during overlap resolution.
29+
softSnapSpans?: { id: string; start: number; end: number }[];
30+
currentTimeMs?: number;
31+
keyframeTimesMs?: number[];
2532
}
2633

34+
interface SnapGuideHandle {
35+
showAt: (timeMs: number) => void;
36+
hide: () => void;
37+
}
38+
39+
// Lives inside TimelineContext so it can read valueToPixels. Updates DOM
40+
// directly via an imperative handle — same pattern as the drag tooltip — to
41+
// avoid re-rendering the timeline on every pointer move.
42+
const SnapGuide = forwardRef<SnapGuideHandle>((_, ref) => {
43+
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
44+
const elRef = useRef<HTMLDivElement>(null);
45+
const sideProperty = direction === "rtl" ? "right" : "left";
46+
47+
useImperativeHandle(
48+
ref,
49+
() => ({
50+
showAt(timeMs: number) {
51+
const el = elRef.current;
52+
if (!el) return;
53+
const offset = valueToPixels(timeMs - range.start) + sidebarWidth;
54+
el.style[sideProperty] = `${offset}px`;
55+
el.style.opacity = "1";
56+
},
57+
hide() {
58+
const el = elRef.current;
59+
if (!el) return;
60+
el.style.opacity = "0";
61+
},
62+
}),
63+
[range.start, sidebarWidth, sideProperty, valueToPixels],
64+
);
65+
66+
return (
67+
<div
68+
ref={elRef}
69+
className="absolute top-0 bottom-0 w-[1px] bg-[#fbbf24] shadow-[0_0_6px_rgba(251,191,36,0.6)] pointer-events-none z-[55]"
70+
style={{ opacity: 0, transition: "opacity 0.08s" }}
71+
/>
72+
);
73+
});
74+
SnapGuide.displayName = "SnapGuide";
75+
2776
export default function TimelineWrapper({
2877
children,
2978
range,
@@ -35,6 +84,9 @@ export default function TimelineWrapper({
3584
gridSizeMs: _gridSizeMs,
3685
onItemSpanChange,
3786
allRegionSpans = [],
87+
softSnapSpans = [],
88+
currentTimeMs,
89+
keyframeTimesMs = [],
3890
}: TimelineWrapperProps) {
3991
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
4092

@@ -127,6 +179,138 @@ export default function TimelineWrapper({
127179
[allRegionSpans, minItemDurationMs, totalMs],
128180
);
129181

182+
const snapGuideRef = useRef<SnapGuideHandle>(null);
183+
184+
// Pull the active span's edges to nearby region boundaries, timeline bounds,
185+
// the playhead, and keyframes. Threshold scales with zoom (~1% of visible
186+
// range, min 50ms) so snap feels right at any zoom level.
187+
// Returns the snapped span plus the actual snap target used (for guide rendering).
188+
const snapSpanToTargets = useCallback(
189+
(
190+
span: Span,
191+
activeItemId: string,
192+
mode: "drag" | "resize-left" | "resize-right",
193+
): { span: Span; snapPoint: number | null } => {
194+
if (totalMs === 0) return { span, snapPoint: null };
195+
196+
const visibleMs = Math.max(range.end - range.start, 1);
197+
const thresholdMs = Math.max(50, Math.round(visibleMs / 100));
198+
199+
const targetSet = new Set<number>();
200+
targetSet.add(0);
201+
targetSet.add(totalMs);
202+
for (const r of allRegionSpans) {
203+
if (r.id === activeItemId) continue;
204+
targetSet.add(r.start);
205+
targetSet.add(r.end);
206+
}
207+
for (const r of softSnapSpans) {
208+
if (r.id === activeItemId) continue;
209+
targetSet.add(r.start);
210+
targetSet.add(r.end);
211+
}
212+
if (currentTimeMs !== undefined) targetSet.add(currentTimeMs);
213+
for (const kf of keyframeTimesMs) targetSet.add(kf);
214+
const targets = Array.from(targetSet);
215+
216+
const findNearest = (value: number): number | null => {
217+
let best: number | null = null;
218+
let bestDistance = thresholdMs;
219+
for (const target of targets) {
220+
const distance = Math.abs(target - value);
221+
if (distance <= bestDistance) {
222+
best = target;
223+
bestDistance = distance;
224+
}
225+
}
226+
return best;
227+
};
228+
229+
if (mode === "resize-left") {
230+
const snap = findNearest(span.start);
231+
if (snap === null || span.end - snap < minItemDurationMs) {
232+
return { span, snapPoint: null };
233+
}
234+
return { span: { start: snap, end: span.end }, snapPoint: snap };
235+
}
236+
237+
if (mode === "resize-right") {
238+
const snap = findNearest(span.end);
239+
if (snap === null || snap - span.start < minItemDurationMs) {
240+
return { span, snapPoint: null };
241+
}
242+
return { span: { start: span.start, end: snap }, snapPoint: snap };
243+
}
244+
245+
// Drag: preserve duration; snap whichever edge is closer to a target.
246+
const startSnap = findNearest(span.start);
247+
const endSnap = findNearest(span.end);
248+
const startDelta = startSnap !== null ? Math.abs(startSnap - span.start) : Infinity;
249+
const endDelta = endSnap !== null ? Math.abs(endSnap - span.end) : Infinity;
250+
251+
if (startDelta === Infinity && endDelta === Infinity) {
252+
return { span, snapPoint: null };
253+
}
254+
255+
const duration = span.end - span.start;
256+
if (startDelta <= endDelta && startSnap !== null) {
257+
return {
258+
span: { start: startSnap, end: startSnap + duration },
259+
snapPoint: startSnap,
260+
};
261+
}
262+
if (endSnap !== null) {
263+
return {
264+
span: { start: endSnap - duration, end: endSnap },
265+
snapPoint: endSnap,
266+
};
267+
}
268+
return { span, snapPoint: null };
269+
},
270+
[
271+
allRegionSpans,
272+
softSnapSpans,
273+
currentTimeMs,
274+
keyframeTimesMs,
275+
minItemDurationMs,
276+
range.end,
277+
range.start,
278+
totalMs,
279+
],
280+
);
281+
282+
// dnd-timeline's resize event doesn't expose direction. Compare the live
283+
// span to the committed one (committed spans only update on commit, so
284+
// during a single resize they still reflect the pre-resize state).
285+
const inferResizeMode = useCallback(
286+
(activeItemId: string, span: Span): "resize-left" | "resize-right" => {
287+
const old =
288+
allRegionSpans.find((r) => r.id === activeItemId) ??
289+
softSnapSpans.find((r) => r.id === activeItemId);
290+
if (!old) return "resize-right";
291+
const startDelta = Math.abs(old.start - span.start);
292+
const endDelta = Math.abs(old.end - span.end);
293+
return startDelta >= endDelta ? "resize-left" : "resize-right";
294+
},
295+
[allRegionSpans, softSnapSpans],
296+
);
297+
298+
const updateSnapGuide = useCallback(
299+
(snapPoint: number | null) => {
300+
if (snapPoint === null) {
301+
snapGuideRef.current?.hide();
302+
return;
303+
}
304+
// Hide the amber guide when it would coincide with the green playhead.
305+
if (currentTimeMs !== undefined && Math.abs(snapPoint - currentTimeMs) < 1) {
306+
snapGuideRef.current?.hide();
307+
return;
308+
}
309+
snapGuideRef.current?.showAt(snapPoint);
310+
},
311+
[currentTimeMs],
312+
);
313+
130314
const onResizeEnd = useCallback(
131315
(event: ResizeEndEvent) => {
132316
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
@@ -135,6 +319,9 @@ export default function TimelineWrapper({
135319
const activeItemId = event.active.id as string;
136320
let clampedSpan = clampSpanToBounds(updatedSpan);
137321

322+
const mode = inferResizeMode(activeItemId, clampedSpan);
323+
clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, mode).span;
324+
138325
const effectiveMinDuration =
139326
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
140327
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
@@ -156,8 +343,10 @@ export default function TimelineWrapper({
156343
clampSpanToBounds,
157344
clampToNeighbours,
158345
hasOverlap,
346+
inferResizeMode,
159347
minItemDurationMs,
160348
onItemSpanChange,
349+
snapSpanToTargets,
161350
totalMs,
162351
],
163352
);
@@ -171,6 +360,8 @@ export default function TimelineWrapper({
171360
const activeItemId = event.active.id as string;
172361
let clampedSpan = clampSpanToBounds(updatedSpan);
173362

363+
clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, "drag").span;
364+
174365
// Clamp to neighbour boundaries instead of rejecting
175366
if (hasOverlap(clampedSpan, activeItemId)) {
176367
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
@@ -181,7 +372,7 @@ export default function TimelineWrapper({
181372

182373
onItemSpanChange(activeItemId, clampedSpan);
183374
},
184-
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange],
375+
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange, snapSpanToTargets],
185376
);
186377

187378
// Drag/resize tooltip (direct DOM updates, no re-renders)
@@ -226,44 +417,60 @@ export default function TimelineWrapper({
226417

227418
const onDragMove = useCallback(
228419
(event: DragMoveEvent) => {
229-
const span = event.active.data.current.getSpanFromDragEvent?.(event);
420+
const rawSpan = event.active.data.current.getSpanFromDragEvent?.(event);
421+
if (!rawSpan) return;
422+
const activeItemId = event.active.id as string;
423+
const clamped = totalMs > 0 ? clampSpanToBounds(rawSpan) : rawSpan;
424+
const { span, snapPoint } = snapSpanToTargets(clamped, activeItemId, "drag");
425+
updateSnapGuide(snapPoint);
230426
const screenX =
231427
event.activatorEvent && "clientX" in event.activatorEvent
232428
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
233429
: undefined;
234-
if (span) showTooltip(span, screenX);
430+
showTooltip(span, screenX);
235431
},
236-
[showTooltip],
432+
[clampSpanToBounds, showTooltip, snapSpanToTargets, totalMs, updateSnapGuide],
237433
);
238434

239435
const onResizeMove = useCallback(
240436
(event: ResizeMoveEvent) => {
241-
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
437+
const rawSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
438+
if (!rawSpan) return;
439+
const activeItemId = event.active.id as string;
440+
const clamped = totalMs > 0 ? clampSpanToBounds(rawSpan) : rawSpan;
441+
const mode = inferResizeMode(activeItemId, clamped);
442+
const { span, snapPoint } = snapSpanToTargets(clamped, activeItemId, mode);
443+
updateSnapGuide(snapPoint);
242444
const screenX =
243445
event.activatorEvent && "clientX" in event.activatorEvent
244446
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
245447
: undefined;
246-
if (span) showTooltip(span, screenX);
448+
showTooltip(span, screenX);
247449
},
248-
[showTooltip],
450+
[clampSpanToBounds, inferResizeMode, showTooltip, snapSpanToTargets, totalMs, updateSnapGuide],
249451
);
250452

251453
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
252454

455+
const hideOverlays = useCallback(() => {
456+
hideTooltip();
457+
snapGuideRef.current?.hide();
458+
}, [hideTooltip]);
459+
253460
const onResizeEndWithTooltip = useCallback(
254461
(event: ResizeEndEvent) => {
255-
hideTooltip();
462+
hideOverlays();
256463
onResizeEnd(event);
257464
},
258-
[hideTooltip, onResizeEnd],
465+
[hideOverlays, onResizeEnd],
259466
);
260467

261468
const onDragEndWithTooltip = useCallback(
262469
(event: DragEndEvent) => {
263-
hideTooltip();
470+
hideOverlays();
264471
onDragEnd(event);
265472
},
266-
[hideTooltip, onDragEnd],
473+
[hideOverlays, onDragEnd],
267474
);
268475

269476
const handleRangeChange = useCallback(
@@ -305,6 +512,7 @@ export default function TimelineWrapper({
305512
>
306513
<div className="relative">
307514
{children}
515+
<SnapGuide ref={snapGuideRef} />
308516
{/* Floating tooltip shown during drag/resize */}
309517
<div
310518
ref={tooltipRef}

0 commit comments

Comments
 (0)