@@ -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" ;
1111import type { Dispatch , ReactNode , SetStateAction } from "react" ;
12- import { useCallback , useRef } from "react" ;
12+ import { forwardRef , useCallback , useImperativeHandle , useRef } from "react" ;
1313
1414interface 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+
2776export 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