@bycrux/editor 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,13 @@
5
5
  // cross-lane drag, edge trim, mute toggle, inline volume, delete,
6
6
  // click-to-select, and inspect button.
7
7
 
8
- import { useEffect, useRef, useState } from 'react'
8
+ import { memo, useEffect, useRef, useState } from 'react'
9
9
  import { Volume2, VolumeX, Trash2, Info } from 'lucide-react'
10
10
  import type { AudioTrack } from '../../schema'
11
11
  import type { Project } from '../../types'
12
12
  import { pct } from './utils'
13
13
  import { useTimelineContext } from './TimelineContext'
14
+ import PlayheadLine from './PlayheadLine'
14
15
  import { useItemDragDrop } from './useItemDragDrop'
15
16
  import type { Draggable, DragEventContext } from './useItemDragDrop'
16
17
  import AudioWaveformLayer from './AudioWaveformLayer'
@@ -46,7 +47,7 @@ function updateAudioTrack(project: Project, trackId: string, changes: Partial<Au
46
47
  }
47
48
  }
48
49
 
49
- export default function AudioTrackRow({
50
+ function AudioTrackRow({
50
51
  tracks,
51
52
  laneIndex,
52
53
  laneCount,
@@ -61,7 +62,6 @@ export default function AudioTrackRow({
61
62
  }: AudioTrackRowProps) {
62
63
  const {
63
64
  totalDuration,
64
- currentTime,
65
65
  snapBoundaries,
66
66
  scrollRef,
67
67
  overlayDraggedRef,
@@ -78,11 +78,7 @@ export default function AudioTrackRow({
78
78
 
79
79
  return (
80
80
  <div className="relative h-10 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer">
81
- {/* Playhead line */}
82
- <div
83
- className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
84
- style={{ left: `${pct(currentTime, totalDuration)}%` }}
85
- />
81
+ <PlayheadLine />
86
82
  {tracks.map(track => (
87
83
  <AudioTrackItem
88
84
  key={track.id}
@@ -139,6 +135,8 @@ export default function AudioTrackRow({
139
135
  )
140
136
  }
141
137
 
138
+ export default memo(AudioTrackRow)
139
+
142
140
  // ── Single audio item within a lane ──────────────────────────────────────────
143
141
 
144
142
  interface AudioTrackItemProps {
@@ -0,0 +1,18 @@
1
+ import { useTimelineContext } from './TimelineContext'
2
+ import { usePlaybackTime } from '../playback-clock'
3
+ import { pct } from './utils'
4
+
5
+ /** Leaf playhead indicator: the ONLY per-tick subscriber inside track rows.
6
+ * Isolating the clock subscription here means VisualTrackRow/AudioTrackRow
7
+ * (and everything else in a row) no longer re-render on every playback tick. */
8
+ export default function PlayheadLine() {
9
+ const { clock, totalDuration } = useTimelineContext()
10
+ const currentTime = usePlaybackTime(clock)
11
+ if (totalDuration === 0) return null
12
+ return (
13
+ <div
14
+ className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
15
+ style={{ left: `${pct(currentTime, totalDuration)}%` }}
16
+ />
17
+ )
18
+ }
@@ -1,5 +1,6 @@
1
1
  import { formatTime, pct, ratioFromClientX } from './utils'
2
2
  import { useTimelineContext } from './TimelineContext'
3
+ import { usePlaybackTime } from '../playback-clock'
3
4
 
4
5
  interface ScrubberProps {
5
6
  hoverPct: number | null
@@ -20,12 +21,13 @@ export default function Scrubber({
20
21
  onCut,
21
22
  cutButtonLabel,
22
23
  }: ScrubberProps) {
23
- const { currentTime, totalDuration, contentDuration, markers, setMarkers, snapBoundaries, onTimeUpdate, scrubberRef, selection } = useTimelineContext()
24
+ const { clock, totalDuration, contentDuration, markers, setMarkers, snapBoundaries, scrubberRef, selection } = useTimelineContext()
25
+ const currentTime = usePlaybackTime(clock)
24
26
 
25
27
  function handleScrubClick(e: React.MouseEvent<HTMLDivElement>) {
26
28
  e.stopPropagation()
27
29
  if (totalDuration === 0) return
28
- onTimeUpdate(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
30
+ clock.set(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
29
31
  }
30
32
 
31
33
  function handleScrubDoubleClick(e: React.MouseEvent<HTMLDivElement>) {
@@ -96,14 +98,14 @@ export default function Scrubber({
96
98
  const rawT = ratioFromClientX(me.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
97
99
  // Already snapped — hold until cursor escapes release radius
98
100
  if (snappedTo !== null) {
99
- if (Math.abs(rawT - snappedTo) < release) { onTimeUpdate(snappedTo); return }
101
+ if (Math.abs(rawT - snappedTo) < release) { clock.set(snappedTo); return }
100
102
  snappedTo = null
101
103
  }
102
104
  // Scan for attraction
103
105
  for (const b of boundaries) {
104
- if (Math.abs(rawT - b) < attract) { snappedTo = b; onTimeUpdate(b); return }
106
+ if (Math.abs(rawT - b) < attract) { snappedTo = b; clock.set(b); return }
105
107
  }
106
- onTimeUpdate(rawT)
108
+ clock.set(rawT)
107
109
  }
108
110
  function onUp() {
109
111
  setDraggingPlayhead(false)
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
1
+ import { useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
2
2
  import AudioTrackRow from './AudioTrackRow'
3
3
  import type { GetWaveformChunks, ResolveFilePath } from './AudioWaveformLayer'
4
4
  import type { Project } from '../../types'
@@ -6,6 +6,7 @@ import { collapseGaps } from '../cuts'
6
6
  import { ratioFromClientX } from './utils'
7
7
  import { useTimelineZoom } from './useTimelineZoom'
8
8
  import { TimelineContext, type TimelineContextValue } from './TimelineContext'
9
+ import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
9
10
  import Scrubber from './Scrubber'
10
11
  import TranscriptPanel from './TranscriptPanel'
11
12
  import TranscriptModal from './TranscriptModal'
@@ -14,11 +15,13 @@ import { deleteSelection, toggleSelection } from './multiSelectOps'
14
15
 
15
16
  interface TimelineProps {
16
17
  project: Project
17
- currentTime: number
18
- onTimeUpdate: (t: number) => void
18
+ clock: PlaybackClock
19
19
  onProjectChange?: (p: Project) => void
20
20
  onCaptionEdit?: (p: Project) => void
21
21
  onOverlayEdit?: (p: Project) => void
22
+ /** Open the overlay props dialog (owned by VideoEditor). Threaded to each
23
+ * VisualTrackRow so a selected overlay block can offer an edit button. */
24
+ onEditOverlay?: (id: string) => void
22
25
  /** Unified selection — covers both visual items and audio tracks. */
23
26
  selectedIds?: string[]
24
27
  onSelectIds?: (ids: string[]) => void
@@ -54,7 +57,7 @@ interface TimelineProps {
54
57
  }
55
58
 
56
59
 
57
- export default function Timeline({ project, currentTime, onTimeUpdate, onProjectChange, onCaptionEdit, onOverlayEdit, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
60
+ export default function Timeline({ project, clock, onProjectChange, onCaptionEdit, onOverlayEdit, onEditOverlay, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
58
61
  const primarySelectedId = selectedIds[0] ?? null
59
62
 
60
63
  // Click/shift-click handler — additive selection on shift or meta (cmd/ctrl).
@@ -66,17 +69,24 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
66
69
  const allTracks = project.tracks ?? []
67
70
  const captionTrack = project.captions
68
71
  const audioTracks = project.audio?.tracks ?? []
69
- const snapBoundaries = [...new Set([
70
- ...allTracks.flat().flatMap(c => [c.start, c.end]),
71
- ...audioTracks.flatMap(t => [t.start, t.end]),
72
- ])]
73
- const contentDuration = Math.max(
74
- allTracks.flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
75
- audioTracks.reduce((m, t) => Math.max(m, t.end ?? 0), 0),
76
- )
77
- // Add 20% padding beyond content so the rightmost item can always be
78
- // dragged or resized further out. Minimum 5s headroom.
79
- const totalDuration = contentDuration + Math.max(5, contentDuration * 0.2)
72
+
73
+ // Memoized so playback ticks (which re-render Timeline via the ctx useMemo's
74
+ // clock dependency) don't recompute these on every frame — they only change
75
+ // when the underlying tracks/audio actually change.
76
+ const { snapBoundaries, contentDuration, totalDuration } = useMemo(() => {
77
+ const snapBoundaries = [...new Set([
78
+ ...allTracks.flat().flatMap(c => [c.start, c.end]),
79
+ ...audioTracks.flatMap(t => [t.start, t.end]),
80
+ ])]
81
+ const contentDuration = Math.max(
82
+ allTracks.flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
83
+ audioTracks.reduce((m, t) => Math.max(m, t.end ?? 0), 0),
84
+ )
85
+ // Add 20% padding beyond content so the rightmost item can always be
86
+ // dragged or resized further out. Minimum 5s headroom.
87
+ const totalDuration = contentDuration + Math.max(5, contentDuration * 0.2)
88
+ return { snapBoundaries, contentDuration, totalDuration }
89
+ }, [project.tracks, project.audio])
80
90
 
81
91
  // Auto-crossfade: when two audio tracks overlap, apply fade-out on the earlier
82
92
  // and fade-in on the later, each equal to the overlap duration.
@@ -149,15 +159,15 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
149
159
  e.preventDefault()
150
160
  const step = e.shiftKey ? 1 : frame
151
161
  const dir = e.key === 'ArrowRight' ? 1 : -1
152
- const next = Math.max(0, Math.min(totalDuration, currentTime + dir * step))
153
- onTimeUpdate(next)
162
+ const next = Math.max(0, Math.min(totalDuration, clock.get() + dir * step))
163
+ clock.set(next)
154
164
  setKeyNavTime(next)
155
165
  if (keyNavTimerRef.current) clearTimeout(keyNavTimerRef.current)
156
166
  keyNavTimerRef.current = setTimeout(() => setKeyNavTime(null), 1500)
157
167
  }
158
168
  document.addEventListener('keydown', onKey)
159
169
  return () => document.removeEventListener('keydown', onKey)
160
- }, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps, transcriptModalOpen])
170
+ }, [totalDuration, clock, project.settings?.fps, transcriptModalOpen])
161
171
 
162
172
  // Derive selection from two placed markers
163
173
  const selection = markers[0] !== null && markers[1] !== null
@@ -166,9 +176,9 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
166
176
 
167
177
  const ctx = useMemo<TimelineContextValue>(() => ({
168
178
  totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
169
- overlayDraggedRef, currentTime, onTimeUpdate, markers, setMarkers, selection,
179
+ overlayDraggedRef, clock, markers, setMarkers, selection,
170
180
  }), [totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
171
- overlayDraggedRef, currentTime, onTimeUpdate, markers, setMarkers, selection])
181
+ overlayDraggedRef, clock, markers, setMarkers, selection])
172
182
 
173
183
  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
174
184
  if ((e.target as HTMLElement).isContentEditable) return
@@ -186,10 +196,11 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
186
196
 
187
197
  if (e.key !== 'Enter' || totalDuration === 0) return
188
198
  e.preventDefault()
199
+ const t = clock.get()
189
200
  setMarkers(([a, b]) => {
190
- if (a === null) return [currentTime, null]
191
- if (b === null) return [a, currentTime]
192
- return [currentTime, null]
201
+ if (a === null) return [t, null]
202
+ if (b === null) return [a, t]
203
+ return [t, null]
193
204
  })
194
205
  }
195
206
 
@@ -202,9 +213,9 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
202
213
  const snapThreshold = (8 / rect.width) * totalDuration
203
214
  const boundaries = snapBoundaries
204
215
  for (const b of boundaries) {
205
- if (Math.abs(clickedTime - b) < snapThreshold) { onTimeUpdate(b); return }
216
+ if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
206
217
  }
207
- onTimeUpdate(clickedTime)
218
+ clock.set(clickedTime)
208
219
  }
209
220
 
210
221
  const cutButtonLabel = primarySelectedId
@@ -292,6 +303,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
292
303
  rippleMode={rippleMode}
293
304
  onProjectChange={onProjectChange}
294
305
  onOverlayEdit={onOverlayEdit}
306
+ onEditOverlay={onEditOverlay}
295
307
  onSelectItem={handleSelectItem}
296
308
  onInspectClip={onInspectClip}
297
309
  subcutClipId={subcutClipId}
@@ -357,10 +369,13 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
357
369
  })()}
358
370
 
359
371
  {/* ── Transcript editor ── */}
360
- <TranscriptPanel
372
+ {/* Wrapped in a clock-subscribing child so the active-segment highlight
373
+ tracks the playhead WITHOUT Timeline (and its track rows) re-rendering
374
+ every tick. */}
375
+ <TranscriptPanelWithClock
376
+ clock={clock}
361
377
  project={project}
362
378
  captionTrack={captionTrack}
363
- currentTime={currentTime}
364
379
  onCaptionEdit={onCaptionEdit}
365
380
  onProjectChange={onProjectChange}
366
381
  onExpand={() => setTranscriptModalOpen(true)}
@@ -369,10 +384,10 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
369
384
 
370
385
  {/* ── Transcript modal ── */}
371
386
  {transcriptModalOpen && (
372
- <TranscriptModal
387
+ <TranscriptModalWithClock
388
+ clock={clock}
373
389
  project={project}
374
390
  captionTrack={captionTrack}
375
- currentTime={currentTime}
376
391
  onProjectChange={onProjectChange}
377
392
  onCaptionEdit={onCaptionEdit}
378
393
  onClose={() => setTranscriptModalOpen(false)}
@@ -383,3 +398,23 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
383
398
  </TimelineContext.Provider>
384
399
  )
385
400
  }
401
+
402
+ // The transcript views highlight the active caption segment, so they genuinely
403
+ // display time and must re-render every tick. Subscribing HERE (rather than in
404
+ // Timeline) keeps the per-tick re-render scoped to these leaves — Timeline and
405
+ // the track rows are unaffected.
406
+ function TranscriptPanelWithClock({
407
+ clock,
408
+ ...rest
409
+ }: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptPanel>, 'currentTime'>) {
410
+ const currentTime = usePlaybackTime(clock)
411
+ return <TranscriptPanel currentTime={currentTime} {...rest} />
412
+ }
413
+
414
+ function TranscriptModalWithClock({
415
+ clock,
416
+ ...rest
417
+ }: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptModal>, 'currentTime'>) {
418
+ const currentTime = usePlaybackTime(clock)
419
+ return <TranscriptModal currentTime={currentTime} {...rest} />
420
+ }
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext } from 'react'
2
+ import type { PlaybackClock } from '../playback-clock'
2
3
 
3
4
  export interface TimelineContextValue {
4
5
  totalDuration: number
@@ -9,8 +10,7 @@ export interface TimelineContextValue {
9
10
  scrollRef: React.RefObject<HTMLDivElement | null>
10
11
  scrubberRef: React.RefObject<HTMLDivElement | null>
11
12
  overlayDraggedRef: React.MutableRefObject<boolean>
12
- currentTime: number
13
- onTimeUpdate: (t: number) => void
13
+ clock: PlaybackClock
14
14
  markers: [number | null, number | null]
15
15
  setMarkers: (m: [number | null, number | null] | ((prev: [number | null, number | null]) => [number | null, number | null])) => void
16
16
  selection: { start: number; end: number } | null
@@ -1,9 +1,11 @@
1
- import { Volume2, VolumeX, Info, Scissors } from 'lucide-react'
1
+ import { memo } from 'react'
2
+ import { Volume2, VolumeX, Info, Scissors, Pencil } from 'lucide-react'
2
3
  import type { VisualItem } from '../../schema'
3
4
  import type { Project } from '../../types'
4
5
  import { collapseGaps } from '../cuts'
5
6
  import { pct, ratioFromClientX, trackRow, trackRowTall } from './utils'
6
7
  import { useTimelineContext } from './TimelineContext'
8
+ import PlayheadLine from './PlayheadLine'
7
9
  import { useItemDragDrop } from './useItemDragDrop'
8
10
  import type { Draggable, DragEventContext } from './useItemDragDrop'
9
11
  import { applyMuteToSelection, applyResizeDeltaToSelection, deleteSelection } from './multiSelectOps'
@@ -17,6 +19,8 @@ interface VisualTrackRowProps {
17
19
  rippleMode: boolean
18
20
  onProjectChange?: (p: Project) => void
19
21
  onOverlayEdit?: (p: Project) => void
22
+ /** Open the overlay props dialog for an overlay item (VideoEditor owns it). */
23
+ onEditOverlay?: (id: string) => void
20
24
  /** Click handler — additive when shift/meta is held. */
21
25
  onSelectItem: (id: string | null, additive: boolean) => void
22
26
  onInspectClip?: (id: string) => void
@@ -41,7 +45,7 @@ const trackColors = [
41
45
  { bg: 'bg-amber-700/60', bgHov: 'hover:bg-amber-700/80', bgSel: 'bg-amber-600/80', ring: 'ring-amber-400/80', border: 'border-amber-500/50', text: 'text-amber-200', resHov: 'hover:bg-amber-300/40' },
42
46
  ]
43
47
 
44
- export default function VisualTrackRow({
48
+ function VisualTrackRow({
45
49
  trackItems,
46
50
  trackIdx,
47
51
  project,
@@ -49,6 +53,7 @@ export default function VisualTrackRow({
49
53
  rippleMode,
50
54
  onProjectChange,
51
55
  onOverlayEdit,
56
+ onEditOverlay,
52
57
  onSelectItem,
53
58
  onInspectClip,
54
59
  subcutClipId,
@@ -56,7 +61,7 @@ export default function VisualTrackRow({
56
61
  regenEnabled,
57
62
  isClipQueued,
58
63
  }: VisualTrackRowProps) {
59
- const { totalDuration, snapBoundaries, scrollRef, scrubberRef, currentTime, onTimeUpdate, markers, setMarkers, selection, overlayDraggedRef, zoomRef } = useTimelineContext()
64
+ const { totalDuration, snapBoundaries, scrollRef, scrubberRef, clock, markers, setMarkers, selection, overlayDraggedRef, zoomRef } = useTimelineContext()
60
65
  const tc = trackColors[trackIdx % trackColors.length]
61
66
  const markerActive = markers[0] !== null || selection !== null
62
67
  const primarySelectedId = selectedIds[0] ?? null
@@ -193,18 +198,11 @@ export default function VisualTrackRow({
193
198
  const snapThreshold = rect ? (8 / rect.width) * totalDuration : 0
194
199
  const boundaries = snapBoundaries
195
200
  for (const b of boundaries) {
196
- if (Math.abs(clickedTime - b) < snapThreshold) { onTimeUpdate(b); return }
201
+ if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
197
202
  }
198
- onTimeUpdate(clickedTime)
203
+ clock.set(clickedTime)
199
204
  }
200
205
 
201
- const playheadLine = (
202
- <div
203
- className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
204
- style={{ left: `${pct(currentTime, totalDuration)}%` }}
205
- />
206
- )
207
-
208
206
  return (
209
207
  <div className={`${trackIdx === 0 ? trackRowTall : trackRow} transition-opacity ${dimmed ? 'opacity-30 pointer-events-none' : ''}`} onClick={handleTrackClick} onDoubleClick={handleScrubDoubleClick}>
210
208
  {trackItems.map((item) => {
@@ -223,7 +221,7 @@ export default function VisualTrackRow({
223
221
  onSelectItem(item.id, additive)
224
222
  // Only seek playhead on a plain single-select click (not on
225
223
  // additive shift-clicks, which shouldn't disrupt scrubbing).
226
- if (!additive && !isSel) onTimeUpdate(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
224
+ if (!additive && !isSel) clock.set(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
227
225
  }}
228
226
  onDoubleClick={(e) => {
229
227
  e.stopPropagation()
@@ -267,6 +265,13 @@ export default function VisualTrackRow({
267
265
  title="Subcut regenerate"
268
266
  ><Scissors size={10} /></button>
269
267
  )}
268
+ {isSel && onEditOverlay && item.type === 'overlay' && !!item.src && (
269
+ <button
270
+ className={`shrink-0 ml-1 z-10 cursor-pointer opacity-60 hover:opacity-100 ${tc.text}`}
271
+ onClick={(e) => { e.stopPropagation(); onEditOverlay(item.id) }}
272
+ title="Edit overlay"
273
+ ><Pencil size={10} /></button>
274
+ )}
270
275
  {isSel && (
271
276
  <button
272
277
  className={`shrink-0 ml-1 mr-3 z-10 cursor-pointer opacity-60 hover:opacity-100 ${tc.text} text-[11px] leading-none`}
@@ -281,7 +286,7 @@ export default function VisualTrackRow({
281
286
  </div>
282
287
  )
283
288
  })}
284
- {playheadLine}
289
+ <PlayheadLine />
285
290
  {selection && (
286
291
  <div
287
292
  className="absolute inset-y-0 bg-red-500/20 pointer-events-none"
@@ -291,3 +296,5 @@ export default function VisualTrackRow({
291
296
  </div>
292
297
  )
293
298
  }
299
+
300
+ export default memo(VisualTrackRow)
@@ -0,0 +1,60 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { render, act } from '@testing-library/react'
3
+ import { createPlaybackClock } from '../../playback-clock'
4
+ import { TimelineContext, type TimelineContextValue } from '../TimelineContext'
5
+ import PlayheadLine from '../PlayheadLine'
6
+
7
+ function makeCtx(overrides: Partial<TimelineContextValue> = {}): TimelineContextValue {
8
+ return {
9
+ totalDuration: 10,
10
+ contentDuration: 10,
11
+ snapBoundaries: [],
12
+ zoom: 1,
13
+ zoomRef: { current: 1 },
14
+ scrollRef: { current: null },
15
+ scrubberRef: { current: null },
16
+ overlayDraggedRef: { current: false },
17
+ clock: createPlaybackClock(),
18
+ markers: [null, null],
19
+ setMarkers: () => {},
20
+ selection: null,
21
+ ...overrides,
22
+ }
23
+ }
24
+
25
+ test('PlayheadLine moves on clock.set without re-rendering a sibling row', () => {
26
+ const ctx = makeCtx()
27
+ let siblingRenders = 0
28
+
29
+ function Sibling() {
30
+ siblingRenders++
31
+ return null
32
+ }
33
+ function Root() {
34
+ return (
35
+ <TimelineContext.Provider value={ctx}>
36
+ <PlayheadLine />
37
+ <Sibling />
38
+ </TimelineContext.Provider>
39
+ )
40
+ }
41
+
42
+ const { container } = render(<Root />)
43
+ const before = siblingRenders
44
+
45
+ act(() => { ctx.clock.set(4) })
46
+
47
+ const line = container.querySelector('div') as HTMLDivElement
48
+ expect(line.style.left).toBe('40%')
49
+ expect(siblingRenders).toBe(before)
50
+ })
51
+
52
+ test('renders nothing when totalDuration is 0', () => {
53
+ const ctx = makeCtx({ totalDuration: 0 })
54
+ const { container } = render(
55
+ <TimelineContext.Provider value={ctx}>
56
+ <PlayheadLine />
57
+ </TimelineContext.Provider>,
58
+ )
59
+ expect(container.firstChild).toBeNull()
60
+ })