@bycrux/editor 0.6.4 → 0.7.1

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.
@@ -1,7 +1,7 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
2
  import { Crop, Magnet } from 'lucide-react'
3
3
  import type { Project, VideoEditorProps } from '../types'
4
- import { VideoSourceCropOverlay } from '../crop/VideoSourceCropOverlay'
4
+ import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
5
5
  import { getOverlayDesignCanvas } from './design-canvas'
6
6
  import { applyTheme, defaultMontajTheme } from '../theme'
7
7
  import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './cuts'
@@ -45,6 +45,7 @@ export default function VideoEditor<P extends Project = Project>({
45
45
  renderSubcutRegen,
46
46
  regenEnabled,
47
47
  isClipQueued,
48
+ onProvideRenderTrigger,
48
49
  }: Props<P>) {
49
50
  const emit = onProjectChange ?? (() => {})
50
51
 
@@ -93,6 +94,7 @@ export default function VideoEditor<P extends Project = Project>({
93
94
  renderSubcutRegen={renderSubcutRegen}
94
95
  regenEnabled={regenEnabled}
95
96
  isClipQueued={isClipQueued}
97
+ onProvideRenderTrigger={onProvideRenderTrigger}
96
98
  />
97
99
  </div>
98
100
  )
@@ -122,6 +124,7 @@ interface SurfaceProps<P extends Project> {
122
124
  getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
123
125
  resolveFilePath: (path: string) => string
124
126
  save: (p: P) => void
127
+ onProvideRenderTrigger?: VideoEditorProps<P>['onProvideRenderTrigger']
125
128
  }
126
129
 
127
130
  function PendingSurface<P extends Project>({
@@ -265,6 +268,7 @@ function ReviewSurface<P extends Project>({
265
268
  renderSubcutRegen,
266
269
  regenEnabled,
267
270
  isClipQueued,
271
+ onProvideRenderTrigger,
268
272
  }: SurfaceProps<P> & {
269
273
  renderClipInspector?: VideoEditorProps<P>['renderClipInspector']
270
274
  renderSubcutRegen?: VideoEditorProps<P>['renderSubcutRegen']
@@ -279,8 +283,8 @@ function ReviewSurface<P extends Project>({
279
283
  const [selectedIds, setSelectedIds] = useState<string[]>([])
280
284
  const primarySelectedId = selectedIds[0] ?? null
281
285
  const [rippleMode, setRippleMode] = useState(false)
282
- // Source-crop mode: when on, a VideoSourceCropOverlay is mounted over the
283
- // preview for the selected tracks[0] video item. Cleared when selection changes.
286
+ // Source-crop mode: when on, the VideoSourceCropModal opens for the selected
287
+ // tracks[0] video item. Cleared when selection changes.
284
288
  const [cropMode, setCropMode] = useState(false)
285
289
  const [renderOpen, setRenderOpen] = useState(false)
286
290
  const [regenCaptionsOpen, setRegenCaptionsOpen] = useState(false)
@@ -288,22 +292,22 @@ function ReviewSurface<P extends Project>({
288
292
  // callbacks. A Montaj-agnostic { kind, id } selector, not a project entity.
289
293
  const [inspecting, setInspecting] = useState<{ kind: 'clip' | 'audio'; id: string } | null>(null)
290
294
 
291
- const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
295
+ // Render trigger — marks the project final, saves, and opens the RenderModal.
296
+ // Kept stable (latest project/onChange/save read via refs) so a host that
297
+ // places Render in its own header (onProvideRenderTrigger) can store the
298
+ // callback once without it going stale.
299
+ const projectRef = useRef(project); projectRef.current = project
300
+ const onProjectChangeRef = useRef(onProjectChange); onProjectChangeRef.current = onProjectChange
301
+ const saveRef = useRef(save); saveRef.current = save
302
+ const openRender = useCallback(() => {
303
+ const final = { ...projectRef.current, status: 'final' } as P
304
+ onProjectChangeRef.current(final)
305
+ saveRef.current(final)
306
+ setRenderOpen(true)
307
+ }, [])
308
+ useEffect(() => { onProvideRenderTrigger?.(openRender) }, [onProvideRenderTrigger, openRender])
292
309
 
293
- // Measured pixel size of the preview's rendered video rect — fed to the crop
294
- // overlay as its wrapper dims so renderedSourceRect letterboxes correctly.
295
- const previewBoxRef = useRef<HTMLDivElement>(null)
296
- const [previewBox, setPreviewBox] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
297
- useEffect(() => {
298
- if (!cropMode) return
299
- const el = previewBoxRef.current
300
- if (!el) return
301
- const obs = new ResizeObserver(([entry]) => {
302
- setPreviewBox({ w: entry.contentRect.width, h: entry.contentRect.height })
303
- })
304
- obs.observe(el)
305
- return () => obs.disconnect()
306
- }, [cropMode])
310
+ const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
307
311
 
308
312
  // Repair caption segments whose words[] text has diverged from edited seg.text.
309
313
  // Inline caption edits update seg.text but not seg.words; this normalizes the
@@ -434,7 +438,6 @@ function ReviewSurface<P extends Project>({
434
438
  <div className="flex-1 flex items-center justify-center bg-black overflow-hidden p-2">
435
439
  {hasContent ? (
436
440
  <div
437
- ref={previewBoxRef}
438
441
  className="relative h-full max-w-full"
439
442
  style={{ aspectRatio: (() => { const [w, h] = getOverlayDesignCanvas(project.settings?.resolution); return `${w} / ${h}` })() }}
440
443
  >
@@ -450,30 +453,6 @@ function ReviewSurface<P extends Project>({
450
453
  fileUrl={adapter.fileUrl}
451
454
  resolveCaptionTemplate={adapter.resolveCaptionTemplate}
452
455
  />
453
- {/* Source-crop overlay — mounted over the preview for the selected
454
- tracks[0] video. Persists sourceCrop through handleOverlayChange. */}
455
- {cropMode && cropTarget && (
456
- <div className="absolute inset-0" style={{ zIndex: 200 }}>
457
- <VideoSourceCropOverlay
458
- item={cropTarget}
459
- resolveSrc={(it) => adapter.fileUrl(it.nobg_preview_src ?? it.src ?? '')}
460
- wrapperWidth={previewBox.w}
461
- wrapperHeight={previewBox.h}
462
- onChange={(next) => handleOverlayChange(cropTarget.id, {
463
- sourceCrop: {
464
- x: Math.min(1, Math.max(0, next.x)),
465
- y: Math.min(1, Math.max(0, next.y)),
466
- w: Math.min(1, Math.max(0, next.w)),
467
- h: Math.min(1, Math.max(0, next.h)),
468
- },
469
- })}
470
- onSrcDimsLoaded={(dims) => {
471
- if (cropTarget.sourceWidth && cropTarget.sourceHeight) return
472
- handleOverlayChange(cropTarget.id, { sourceWidth: dims.width, sourceHeight: dims.height })
473
- }}
474
- />
475
- </div>
476
- )}
477
456
  </div>
478
457
  ) : (
479
458
  <p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
@@ -522,17 +501,16 @@ function ReviewSurface<P extends Project>({
522
501
  >
523
502
  <Crop size={12} />
524
503
  </button>
525
- <button
526
- onClick={() => {
527
- const final = { ...project, status: 'final' } as P
528
- onProjectChange(final)
529
- save(final)
530
- setRenderOpen(true)
531
- }}
532
- className="text-xs px-2.5 py-1 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors"
533
- >
534
- Render →
535
- </button>
504
+ {/* Default placement. A host that sets onProvideRenderTrigger renders
505
+ Render in its own chrome instead, so the toolbar button is hidden. */}
506
+ {!onProvideRenderTrigger && (
507
+ <button
508
+ onClick={openRender}
509
+ className="text-xs px-2.5 py-1 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors"
510
+ >
511
+ Render →
512
+ </button>
513
+ )}
536
514
  </div>
537
515
 
538
516
  <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
@@ -606,6 +584,31 @@ function ReviewSurface<P extends Project>({
606
584
  </div>
607
585
  )}
608
586
 
587
+ {/* Source-crop modal — drag-to-pan, aspect presets, zoom. Commits
588
+ sourceCrop (+ source dims) to the selected tracks[0] video on Apply. */}
589
+ {cropMode && cropTarget && (
590
+ <VideoSourceCropModal
591
+ item={cropTarget}
592
+ // Prefer the conformed per-window cache (short, starts at the clip's
593
+ // first frame → loads instantly and shows a representative frame).
594
+ // Falls back to the bg-removed proxy, then the raw source.
595
+ resolveSrc={(it) => adapter.fileUrl(it.nobg_preview_src ?? it.normalizedSrc ?? it.src ?? '')}
596
+ onApply={(next) => handleOverlayChange(cropTarget.id, {
597
+ sourceCrop: {
598
+ x: Math.min(1, Math.max(0, next.x)),
599
+ y: Math.min(1, Math.max(0, next.y)),
600
+ w: Math.min(1, Math.max(0, next.w)),
601
+ h: Math.min(1, Math.max(0, next.h)),
602
+ },
603
+ })}
604
+ onSrcDimsLoaded={(dims) => {
605
+ if (cropTarget.sourceWidth && cropTarget.sourceHeight) return
606
+ handleOverlayChange(cropTarget.id, { sourceWidth: dims.width, sourceHeight: dims.height })
607
+ }}
608
+ onClose={() => setCropMode(false)}
609
+ />
610
+ )}
611
+
609
612
  {/* Render modal — adapter.render stream + host export controls */}
610
613
  {renderOpen && (
611
614
  <RenderModal
@@ -1,4 +1,5 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react'
2
+ import { videoTransformContainerStyle, videoTransformBoxPct, type VideoTransform } from './transformStyle'
2
3
  import type { EditorProject as Project } from '../../schema'
3
4
  import type { OverlayFactory } from '../../types'
4
5
  import CaptionPreview from './CaptionPreview'
@@ -119,6 +120,71 @@ export default function PreviewPlayer({
119
120
  ? { ...cropStyle }
120
121
  : { position: 'absolute' as const, inset: 0, width: '100%', height: '100%', objectFit: 'contain' as const }
121
122
 
123
+ // ── Base-video on-canvas transform (position + zoom) ─────────────────────────
124
+ // The selected tracks[0] video shows drag/resize handles like an overlay: drag
125
+ // to move (offsetX/offsetY), corner-drag or scroll to scale. Live during a
126
+ // pointer drag, committed on pointer-up via onOverlayChange; scroll commits
127
+ // directly. The <video> container reflects it (WYSIWYG with the renderer).
128
+ const selectedClip = useMemo(
129
+ () => clips.find(c => c.id === selectedOverlayId) ?? null,
130
+ [clips, selectedOverlayId],
131
+ )
132
+ const showVideoTransform = !isCanvasProject && !!selectedClip && selectedClip.id === activeClip?.id
133
+ const [liveXf, setLiveXf] = useState<VideoTransform | null>(null)
134
+ const xfDragRef = useRef<
135
+ | { kind: 'move'; startClient: { x: number; y: number }; start: VideoTransform }
136
+ | { kind: 'scale'; center: { x: number; y: number }; startDist: number; start: VideoTransform }
137
+ | null
138
+ >(null)
139
+ const baseXf: VideoTransform = {
140
+ scale: activeClip?.scale ?? 1,
141
+ offsetX: activeClip?.offsetX ?? 0,
142
+ offsetY: activeClip?.offsetY ?? 0,
143
+ }
144
+ const xf = liveXf ?? baseXf
145
+ const transformContainerStyle = videoTransformContainerStyle(xf)
146
+ const xfBox = videoTransformBoxPct(xf)
147
+
148
+ const onXfMoveDown = (e: ReactPointerEvent) => {
149
+ e.stopPropagation(); e.currentTarget.setPointerCapture?.(e.pointerId)
150
+ xfDragRef.current = { kind: 'move', startClient: { x: e.clientX, y: e.clientY }, start: baseXf }
151
+ }
152
+ const onXfScaleDown = (e: ReactPointerEvent) => {
153
+ e.stopPropagation(); e.currentTarget.setPointerCapture?.(e.pointerId)
154
+ const r = containerRef.current?.getBoundingClientRect()
155
+ if (!r) return
156
+ const center = { x: r.left + r.width / 2, y: r.top + r.height / 2 }
157
+ const startDist = Math.hypot(e.clientX - center.x, e.clientY - center.y) || 1
158
+ xfDragRef.current = { kind: 'scale', center, startDist, start: baseXf }
159
+ }
160
+ const onXfMove = (e: ReactPointerEvent) => {
161
+ const d = xfDragRef.current
162
+ if (!d) return
163
+ if (d.kind === 'move') {
164
+ const ox = (d.start.offsetX ?? 0) + ((e.clientX - d.startClient.x) / (frameSize.w || 1)) * 100
165
+ const oy = (d.start.offsetY ?? 0) + ((e.clientY - d.startClient.y) / (frameSize.h || 1)) * 100
166
+ setLiveXf({ ...d.start, offsetX: ox, offsetY: oy })
167
+ } else {
168
+ const dist = Math.hypot(e.clientX - d.center.x, e.clientY - d.center.y)
169
+ const s = Math.min(8, Math.max(0.2, (d.start.scale ?? 1) * (dist / d.startDist)))
170
+ setLiveXf({ ...d.start, scale: s })
171
+ }
172
+ }
173
+ const onXfUp = () => {
174
+ const live = liveXf
175
+ xfDragRef.current = null
176
+ setLiveXf(null)
177
+ if (live && selectedClip && onOverlayChange) {
178
+ onOverlayChange(selectedClip.id, { offsetX: live.offsetX, offsetY: live.offsetY, scale: live.scale })
179
+ }
180
+ }
181
+ const onXfWheel = (e: ReactWheelEvent) => {
182
+ if (!showVideoTransform || !selectedClip || !onOverlayChange) return
183
+ const factor = e.deltaY < 0 ? 1.06 : 1 / 1.06
184
+ const s = Math.min(8, Math.max(0.2, (activeClip?.scale ?? 1) * factor))
185
+ onOverlayChange(selectedClip.id, { scale: s })
186
+ }
187
+
122
188
  return (
123
189
  <div ref={containerRef} className="relative bg-black h-full max-w-full overflow-hidden rounded" style={{ aspectRatio: `${RENDER_W} / ${RENDER_H}`, isolation: 'isolate' }}>
124
190
  {isCanvasProject ? (
@@ -128,7 +194,10 @@ export default function PreviewPlayer({
128
194
  No clips
129
195
  </div>
130
196
  ) : (
131
- <>
197
+ // Transform container — applies the active clip's scale/offset to both
198
+ // slots (the inactive one is invisible). The frame's overflow-hidden clips
199
+ // anything pushed outside. Mirrors the renderer's crop→scale→position.
200
+ <div className="absolute inset-0" style={transformContainerStyle}>
132
201
  {/* Slot 0 */}
133
202
  <video
134
203
  ref={video0Ref}
@@ -161,7 +230,45 @@ export default function PreviewPlayer({
161
230
  playsInline
162
231
  style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
163
232
  />
164
- </>
233
+ </div>
234
+ )}
235
+
236
+ {/* Base-video transform handles — drag to move, corner-drag or scroll to
237
+ zoom. Shown only when the on-screen clip is selected. Above the
238
+ play-toggle (z 10), below overlays (z 12+). */}
239
+ {showVideoTransform && (
240
+ <div className="absolute inset-0 pointer-events-none" style={{ zIndex: 11 }}>
241
+ <div
242
+ onPointerDown={onXfMoveDown}
243
+ onPointerMove={onXfMove}
244
+ onPointerUp={onXfUp}
245
+ onWheel={onXfWheel}
246
+ style={{
247
+ position: 'absolute',
248
+ left: `${xfBox.left}%`, top: `${xfBox.top}%`, width: `${xfBox.width}%`, height: `${xfBox.height}%`,
249
+ outline: '2px solid var(--editor-selection)', cursor: 'move', pointerEvents: 'auto', touchAction: 'none',
250
+ }}
251
+ />
252
+ {([['nw', 0, 0], ['ne', 1, 0], ['sw', 0, 1], ['se', 1, 1]] as const).map(([k, dx, dy]) => (
253
+ <div
254
+ key={k}
255
+ onPointerDown={onXfScaleDown}
256
+ onPointerMove={onXfMove}
257
+ onPointerUp={onXfUp}
258
+ style={{
259
+ position: 'absolute',
260
+ // Clamp into the frame so corners stay grabbable even when the box
261
+ // is scaled beyond the frame (scale uses pointer-distance, not the
262
+ // handle's position, so a clamped grab still scales correctly).
263
+ left: `calc(${Math.min(98, Math.max(2, xfBox.left + dx * xfBox.width))}% - 6px)`,
264
+ top: `calc(${Math.min(98, Math.max(2, xfBox.top + dy * xfBox.height))}% - 6px)`,
265
+ width: 12, height: 12, backgroundColor: '#fff',
266
+ border: '1.5px solid var(--editor-selection)', borderRadius: 2,
267
+ cursor: 'nwse-resize', pointerEvents: 'auto', touchAction: 'none',
268
+ }}
269
+ />
270
+ ))}
271
+ </div>
165
272
  )}
166
273
 
167
274
 
@@ -0,0 +1,34 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { videoTransformContainerStyle, videoTransformBoxPct } from '../transformStyle'
3
+
4
+ describe('videoTransformContainerStyle', () => {
5
+ it('identity transform → empty style (no-op)', () => {
6
+ expect(videoTransformContainerStyle({})).toEqual({})
7
+ expect(videoTransformContainerStyle({ scale: 1, offsetX: 0, offsetY: 0 })).toEqual({})
8
+ })
9
+
10
+ it('scale + offset → translate then scale, origin center', () => {
11
+ const s = videoTransformContainerStyle({ scale: 2, offsetX: 10, offsetY: -5 })
12
+ expect(s.transform).toBe('translate(10%, -5%) scale(2)')
13
+ expect(s.transformOrigin).toBe('center center')
14
+ })
15
+ })
16
+
17
+ describe('videoTransformBoxPct', () => {
18
+ it('scale 1, no offset → fills the frame', () => {
19
+ expect(videoTransformBoxPct({})).toEqual({ width: 100, height: 100, left: 0, top: 0 })
20
+ })
21
+
22
+ it('scale 2 centered → 200% box, centered (offset -50%)', () => {
23
+ expect(videoTransformBoxPct({ scale: 2 })).toEqual({ width: 200, height: 200, left: -50, top: -50 })
24
+ })
25
+
26
+ it('scale 0.5 → 50% box centered at 25%,25%', () => {
27
+ expect(videoTransformBoxPct({ scale: 0.5 })).toEqual({ width: 50, height: 50, left: 25, top: 25 })
28
+ })
29
+
30
+ it('offset shifts the centered box by frame percent', () => {
31
+ expect(videoTransformBoxPct({ scale: 2, offsetX: 10, offsetY: -5 }))
32
+ .toEqual({ width: 200, height: 200, left: -40, top: -55 })
33
+ })
34
+ })
@@ -0,0 +1,44 @@
1
+ // Pure geometry for the base video's on-canvas TRANSFORM (position + zoom),
2
+ // distinct from its sourceCrop (which sub-rect of the footage). Mirrors the
3
+ // renderer (encode-segment.js buildVideoItemFilterParts): the cropped/contained
4
+ // video is fit into a box of size (canvas × scale), centered, then shifted by
5
+ // offsetX/offsetY which are percentages of the frame — `overlay=x=vw*(0.5*(1-s)
6
+ // + offsetX/100)`. The frame's overflow-hidden clips anything outside.
7
+ //
8
+ // Both helpers are expressed in frame-relative units so they're pixel-size
9
+ // independent: the container transform uses CSS translate %, which is relative to
10
+ // the container's own (frame) size, and scale() around center.
11
+
12
+ import type { CSSProperties } from 'react'
13
+
14
+ export interface VideoTransform {
15
+ scale?: number
16
+ offsetX?: number // percent of frame width
17
+ offsetY?: number // percent of frame height
18
+ }
19
+
20
+ // CSS transform for a frame-sized container wrapping the <video>. translate() %
21
+ // is relative to the container (= frame), matching the renderer's frame-percent
22
+ // offset; scale() is around center, matching the renderer's centered box.
23
+ export function videoTransformContainerStyle(t: VideoTransform): CSSProperties {
24
+ const s = t.scale ?? 1
25
+ const ox = t.offsetX ?? 0
26
+ const oy = t.offsetY ?? 0
27
+ if (s === 1 && ox === 0 && oy === 0) return {}
28
+ return { transform: `translate(${ox}%, ${oy}%) scale(${s})`, transformOrigin: 'center center' }
29
+ }
30
+
31
+ // The transform box as a frame-relative % rect (left/top/width/height in %).
32
+ // This is the canvas-aspect box the cropped video is contained within; the crop
33
+ // handles for the on-canvas transform are drawn on it.
34
+ export function videoTransformBoxPct(t: VideoTransform): { left: number; top: number; width: number; height: number } {
35
+ const s = t.scale ?? 1
36
+ const ox = t.offsetX ?? 0
37
+ const oy = t.offsetY ?? 0
38
+ return {
39
+ width: s * 100,
40
+ height: s * 100,
41
+ left: ((1 - s) / 2) * 100 + ox,
42
+ top: ((1 - s) / 2) * 100 + oy,
43
+ }
44
+ }
@@ -610,6 +610,14 @@ export function useVideoPlayback(
610
610
  gapNextIdxRef.current = nextIdx
611
611
  getActiveVideo()?.pause()
612
612
  gapRAFRef.current = requestAnimationFrame(tickGap)
613
+ } else {
614
+ // Scrubbed PAST the last clip into trailing empty space — there is no
615
+ // next clip to advance to. Without this the active <video> keeps
616
+ // playing under the hidden (showVideo=false) frame: the picture goes
617
+ // dark but its audio keeps going. Stop playback outright.
618
+ getActiveVideo()?.pause()
619
+ setIsPlaying(false)
620
+ syncAudioTracks(currentTime, false)
613
621
  }
614
622
  }
615
623
  return
@@ -131,7 +131,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
131
131
 
132
132
  const [subcutClipId, setSubcutClipId] = useState<string | null>(null)
133
133
 
134
- const { zoom, zoomRef, scrollRef, zoomTo, handleTimelineWheel } = useTimelineZoom(totalDuration)
134
+ const { zoom, zoomRef, scrollRef, zoomTo } = useTimelineZoom(totalDuration)
135
135
 
136
136
  useEffect(() => {
137
137
  if (totalDuration === 0) return
@@ -245,7 +245,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
245
245
  )}
246
246
 
247
247
  {/* Scroll container for zoomed tracks */}
248
- <div ref={scrollRef} className="overflow-x-auto" onWheel={handleTimelineWheel}>
248
+ <div ref={scrollRef} className="overflow-x-auto">
249
249
  <div style={{ width: zoom > 1 ? `${zoom * 100}%` : '100%' }} className="min-w-full">
250
250
 
251
251
  {/* Scrubber + tracks wrapped in a relative container so the hover indicator spans the full height */}
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef, useState } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
 
3
3
  export function useTimelineZoom(totalDuration: number) {
4
4
  const [zoom, setZoom] = useState(1)
@@ -31,7 +31,14 @@ export function useTimelineZoom(totalDuration: number) {
31
31
  setZoom(clamped)
32
32
  }
33
33
 
34
- function handleTimelineWheel(e: React.WheelEvent) {
34
+ // ⌘/Ctrl + wheel = zoom; Alt + wheel = horizontal scroll. Both call
35
+ // preventDefault, so the listener MUST be non-passive. React's onWheel prop is
36
+ // registered passive at the root, which makes preventDefault a no-op and spams
37
+ // "Unable to preventDefault inside passive event listener" — so attach a native
38
+ // listener with { passive: false } instead. A ref to the latest handler keeps
39
+ // it current without re-binding on every render.
40
+ const wheelHandlerRef = useRef<(e: WheelEvent) => void>(() => {})
41
+ wheelHandlerRef.current = (e: WheelEvent) => {
35
42
  if (e.ctrlKey || e.metaKey) {
36
43
  e.preventDefault()
37
44
  // Multiplicative step so perceived speed is consistent across zoom levels.
@@ -44,5 +51,13 @@ export function useTimelineZoom(totalDuration: number) {
44
51
  }
45
52
  }
46
53
 
47
- return { zoom, zoomRef, scrollRef, pendingScrollRef, zoomTo, handleTimelineWheel }
54
+ useEffect(() => {
55
+ const el = scrollRef.current
56
+ if (!el) return
57
+ const onWheel = (e: WheelEvent) => wheelHandlerRef.current(e)
58
+ el.addEventListener('wheel', onWheel, { passive: false })
59
+ return () => el.removeEventListener('wheel', onWheel)
60
+ }, [])
61
+
62
+ return { zoom, zoomRef, scrollRef, pendingScrollRef, zoomTo }
48
63
  }