@bycrux/editor 0.11.2 → 1.0.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.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,1447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TimelineCanvas (SP5 T4) — the canvas track-row area. It is the ONLY track
|
|
3
|
+
* surface: it carries the visual tracks, the audio lanes and the caption rows
|
|
4
|
+
* alike, and the DOM rows it replaced (which for a while it was mounted in
|
|
5
|
+
* place of, behind a `timeline.canvas` prop) are gone.
|
|
6
|
+
*
|
|
7
|
+
* ── How this stays fast ──────────────────────────────────────────────────
|
|
8
|
+
* Three rules, all of them about NOT re-rendering React:
|
|
9
|
+
*
|
|
10
|
+
* 1. Two stacked canvases. Content (rows, clips, audio) on the lower
|
|
11
|
+
* one, the playhead alone on the upper one. Playback moves the playhead ~60
|
|
12
|
+
* times a second; on a single canvas each move would have to repaint every
|
|
13
|
+
* clip, which is precisely the cost this surface exists to avoid.
|
|
14
|
+
* 2. The playhead subscribes to the playback clock directly, the way the DOM
|
|
15
|
+
* path's `PlayheadLine` did — except the subscription drives an imperative
|
|
16
|
+
* redraw instead of a React render, so nothing above it re-renders.
|
|
17
|
+
* 3. Zoom/scroll live in an external store (viewport.ts), not React state, so
|
|
18
|
+
* a wheel-zoom gesture never re-renders Timeline. Only the zoom badge
|
|
19
|
+
* subscribes for display.
|
|
20
|
+
*
|
|
21
|
+
* All redraws funnel through `requestRedraw`, which coalesces to one rAF and
|
|
22
|
+
* repaints only the layers marked dirty.
|
|
23
|
+
*
|
|
24
|
+
* ── Pointer interaction (SP5 T5) ─────────────────────────────────────────
|
|
25
|
+
* Every gesture — seek, select, move, trim, roll/slip/slide — is
|
|
26
|
+
* decided by `pointer-machine.ts`, which is pure. This file does only the three
|
|
27
|
+
* things a pure reducer cannot: it turns mouse events into surface-space
|
|
28
|
+
* points, it hands the machine a fresh view of the world on each event, and it
|
|
29
|
+
* performs the effects the machine returns. Listeners follow the same pattern
|
|
30
|
+
* the wheel handler established (a ref to the latest closure, bound once on
|
|
31
|
+
* mount) so a drag never re-binds anything; the document-level move/up pair is
|
|
32
|
+
* attached for the duration of a gesture only, exactly as the DOM rows' drag
|
|
33
|
+
* hook does it.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
37
|
+
import type { GetFilmstripArgs, GetWaveformPeaksArgs, FilmstripIndex, PeaksData, PendingDrop, Project, FootageDropPayload, ResolveFilePath, TimelineDropPlacement } from '../../../types'
|
|
38
|
+
import { FOOTAGE_DND_MIME } from '../../../types'
|
|
39
|
+
import type { KeyframeProp } from '../../../schema'
|
|
40
|
+
import type { PlaybackClock } from '../../playback-clock'
|
|
41
|
+
import { BASE_VISUAL_ROW_RENDER_HEIGHT_PX, ROW_GAP_PX, VISUAL_ROW_RENDER_HEIGHT_PX } from '../timeline-model'
|
|
42
|
+
import { placeDroppedClip, resolveDropPoint } from '../placement'
|
|
43
|
+
import { computeTimelineLayout, drawTimelineContent, drawTimelineOverlay, type PendingDropBand, type TimelineLayout, type TimelineMode, type VisualRowLayout } from './draw'
|
|
44
|
+
import { hitTest, isEdgeHit, type Point, type SurfaceRect } from './hit-test'
|
|
45
|
+
import { keyframeUnionTimes } from './keyframe-strip'
|
|
46
|
+
import {
|
|
47
|
+
createPointerMachine,
|
|
48
|
+
type KeyframeSelection,
|
|
49
|
+
type Modifiers,
|
|
50
|
+
type PointerContext,
|
|
51
|
+
type PointerEffect,
|
|
52
|
+
} from './pointer-machine'
|
|
53
|
+
import { DEFAULT_SNAP_CONFIG, type SnapStrength } from './snap'
|
|
54
|
+
import { WaveformPeaksStore, type WaveformSceneLookup } from './waveforms'
|
|
55
|
+
import { FilmstripStore, type FilmstripSceneLookup } from './filmstrips'
|
|
56
|
+
import {
|
|
57
|
+
EDGE_SCROLL_MAX_PX_PER_SEC,
|
|
58
|
+
EDGE_SCROLL_RAMP_PX,
|
|
59
|
+
EDGE_SCROLL_ZONE_PX,
|
|
60
|
+
ZOOM_BUTTON_FACTOR,
|
|
61
|
+
applyWheelIntent,
|
|
62
|
+
clampScrollSeconds,
|
|
63
|
+
edgeScrollDelta,
|
|
64
|
+
fitViewport,
|
|
65
|
+
formatZoomMultiple,
|
|
66
|
+
observeSurface,
|
|
67
|
+
reclampForDuration,
|
|
68
|
+
scaleContextToDpr,
|
|
69
|
+
syncCanvasBackingStore,
|
|
70
|
+
useViewportValue,
|
|
71
|
+
wheelIntent,
|
|
72
|
+
withSurfaceWidth,
|
|
73
|
+
xToTime,
|
|
74
|
+
zoomAtPivot,
|
|
75
|
+
zoomMultiple,
|
|
76
|
+
type SurfaceMetrics,
|
|
77
|
+
type ViewportStore,
|
|
78
|
+
} from './viewport'
|
|
79
|
+
|
|
80
|
+
export interface TimelineCanvasProps {
|
|
81
|
+
project: Project
|
|
82
|
+
clock: PlaybackClock
|
|
83
|
+
/** Shared with Timeline's zoom chrome — see `useCanvasZoomControls`. */
|
|
84
|
+
store: ViewportStore
|
|
85
|
+
/** Content duration plus the timeline's drag headroom (timeline-model). */
|
|
86
|
+
totalDuration: number
|
|
87
|
+
/** The project's frame rate. Used for exactly one thing — a click on a
|
|
88
|
+
* caption seeks half a frame into the segment rather than to its start, so
|
|
89
|
+
* the preview's frame-snapped clock lands inside it. See the seek in
|
|
90
|
+
* `pointer-machine`'s `pointerUp`. */
|
|
91
|
+
fps: number
|
|
92
|
+
/** Unified selection: visual items, audio tracks and caption segments alike.
|
|
93
|
+
* Captions share this ONE array rather than a channel of their own, which is
|
|
94
|
+
* what lets a mixed clip+caption drag commit as a single undo entry. */
|
|
95
|
+
selectedIds: string[]
|
|
96
|
+
/** Clip and audio boundaries the gestures snap to — Timeline's existing
|
|
97
|
+
* `computeDerivedTiming` memo, shared with the DOM rows. Absent means no
|
|
98
|
+
* magnetism, which is the right degradation rather than an error. */
|
|
99
|
+
snapBoundaries?: number[]
|
|
100
|
+
/** Trims close the gap they open, as they do on the DOM rows. */
|
|
101
|
+
rippleMode?: boolean
|
|
102
|
+
/** CapCut's "preview axis", off by default. On, a yellow cursor line tracks
|
|
103
|
+
* the pointer across this surface and `onHoverScrub` reports the time under
|
|
104
|
+
* it, so the host can show that frame in the preview while the playhead
|
|
105
|
+
* stays where it is. Off, this surface behaves exactly as it always has:
|
|
106
|
+
* no cursor line, and clicking seeks as usual. Pointer gestures are
|
|
107
|
+
* identical either way — the toggle adds a hover affordance, it does not
|
|
108
|
+
* change what a click does. */
|
|
109
|
+
previewAxis?: boolean
|
|
110
|
+
/** The time under the pointer while the axis is on, or null when the pointer
|
|
111
|
+
* leaves or a gesture starts. Fires per mousemove, so the host must route it
|
|
112
|
+
* to an external store rather than React state. */
|
|
113
|
+
onHoverScrub?: (time: number | null) => void
|
|
114
|
+
/** Timeline's `handleSelectItem` — additive rules and the item↔caption
|
|
115
|
+
* exclusivity stay owned there, so both surfaces select identically. */
|
|
116
|
+
onSelectItem?: (id: string | null, additive: boolean) => void
|
|
117
|
+
/** A marquee's whole catch, applied in one step. Falls back to replaying
|
|
118
|
+
* `onSelectItem` per id when the host does not implement it, so a host that
|
|
119
|
+
* predates the marquee still selects correctly. */
|
|
120
|
+
onSelectItems?: (ids: string[], additive: boolean) => void
|
|
121
|
+
/** The currently selected keyframe, drawn filled. Null when none. */
|
|
122
|
+
selectedKeyframe?: KeyframeSelection | null
|
|
123
|
+
onSelectKeyframe?: (selection: KeyframeSelection | null) => void
|
|
124
|
+
/** Live, uncommitted edit — fires once per pointer move during a gesture. */
|
|
125
|
+
onProjectChange?: (p: Project) => void
|
|
126
|
+
/** Gesture finished; persist. Same split the DOM rows use. */
|
|
127
|
+
onOverlayEdit?: (p: Project) => void
|
|
128
|
+
onInspectClip?: (id: string) => void
|
|
129
|
+
onInspectAudio?: (id: string) => void
|
|
130
|
+
/** Double-click on a caption block. A caption has no inspector dialog, so
|
|
131
|
+
* this is not `onInspectClip`'s sibling: it asks the host to focus that
|
|
132
|
+
* segment's row in the transcript sidebar, where caption text is edited. */
|
|
133
|
+
onEditCaption?: (id: string) => void
|
|
134
|
+
/** Right-click on an audio bar's fade GRIP — Vegas' own gesture for picking
|
|
135
|
+
* a fade's shape. `x`/`y` are CLIENT coordinates (not surface-relative,
|
|
136
|
+
* unlike every other callback here) because the host renders the picker as
|
|
137
|
+
* an absolutely-positioned DOM menu of its own, outside this canvas
|
|
138
|
+
* surface, and a screen position is what CSS `left`/`top` need. Absent →
|
|
139
|
+
* a right-click on a fade grip falls through to the browser's own context
|
|
140
|
+
* menu, same as anywhere else on the surface. */
|
|
141
|
+
onFadeCurveMenu?: (args: { trackId: string; side: 'in' | 'out'; x: number; y: number }) => void
|
|
142
|
+
/** Right-click on a keyframe-strip diamond (SP9b T3.3) — `onFadeCurveMenu`'s
|
|
143
|
+
* sibling for the keyframe-strip's own popup: the host is expected to
|
|
144
|
+
* offer the six `EASING_NAMES` (via `keyframeOps.setKeyframeEasing`) AND a
|
|
145
|
+
* way to remove the keyframe (via `keyframeOps.removeKeyframe`), applied
|
|
146
|
+
* once per entry in `props` — every keyframe track that has a point at
|
|
147
|
+
* `t`, which is every prop this ONE diamond represents (plan decision 2).
|
|
148
|
+
* `x`/`y` are CLIENT coordinates, same reason `onFadeCurveMenu`'s are.
|
|
149
|
+
* `isLast` is true when `t` is the item's LAST keyframe: its easing (which
|
|
150
|
+
* governs the segment INTO the next keyframe — see `Keyframe.easing`'s
|
|
151
|
+
* doc in schema.ts) has no next keyframe to reach, so a host offering the
|
|
152
|
+
* easing picker should grey it out or omit it there — removal still
|
|
153
|
+
* applies regardless. Absent → a right-click on a diamond falls through to
|
|
154
|
+
* the browser's own context menu. */
|
|
155
|
+
onKeyframeMenu?: (args: { itemId: string; t: number; props: KeyframeProp[]; isLast: boolean; x: number; y: number }) => void
|
|
156
|
+
/** T6 — the host adapter's peaks fetcher, threaded from
|
|
157
|
+
* `adapter.getWaveformPeaks` via Timeline. Absent → no waveforms anywhere
|
|
158
|
+
* (graceful; the surface just never asks). */
|
|
159
|
+
getWaveformPeaks?: (args: GetWaveformPeaksArgs) => Promise<PeaksData>
|
|
160
|
+
/** T7 — the host adapter's filmstrip fetcher, threaded from
|
|
161
|
+
* `adapter.getFilmstrip` via Timeline. Absent → no filmstrips or
|
|
162
|
+
* hover-scrub thumbs anywhere (graceful). */
|
|
163
|
+
getFilmstrip?: (args: GetFilmstripArgs) => Promise<FilmstripIndex>
|
|
164
|
+
/** T7 — resolves a filmstrip sheet's host path into a displayable URL, the
|
|
165
|
+
* SAME mechanism `WaveformChunk.path` uses for the DOM waveform PNGs
|
|
166
|
+
* (`adapter.fileUrl`). Threaded from Timeline's own `resolveFilePath`. */
|
|
167
|
+
resolveFilePath?: ResolveFilePath
|
|
168
|
+
/** Which ground the painter draws on, resolved from the host theme by
|
|
169
|
+
* `VideoEditor` (see `isLightTheme`) and threaded through Timeline. The
|
|
170
|
+
* canvas cannot read a CSS variable, so this is how a theme flip reaches
|
|
171
|
+
* the pixels. Defaults to `'dark'` — the only mode this surface had — so a
|
|
172
|
+
* host that never passes it is unchanged. */
|
|
173
|
+
mode?: TimelineMode
|
|
174
|
+
/** A drop of real OS FILES onto this surface, threaded from
|
|
175
|
+
* `VideoEditorProps.onImportFilesToTimeline` (see its doc for the contract).
|
|
176
|
+
* Its PRESENCE is what makes this surface accept an OS-file drag at all:
|
|
177
|
+
* absent, `dragover` never calls `preventDefault` for one, so the browser
|
|
178
|
+
* keeps its own handling and the whole gesture is inert — which is exactly
|
|
179
|
+
* what a host that predates this feature gets. */
|
|
180
|
+
onImportFilesToTimeline?: (files: File[], placement: TimelineDropPlacement) => void
|
|
181
|
+
/** Ghost bands for the host's in-flight imports, drawn on the overlay layer.
|
|
182
|
+
* Absent/empty → nothing extra is painted. */
|
|
183
|
+
pendingDrops?: readonly PendingDrop[]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Which video row a drop at surface-y `y` landed on, as an index into the
|
|
188
|
+
* NORMALIZED track order `placeDroppedClip` measures in — or `-1` for "no
|
|
189
|
+
* preference", which is what the ruler, a caption band, an audio lane and the
|
|
190
|
+
* gap between two rows all resolve to.
|
|
191
|
+
*
|
|
192
|
+
* Reads the layout's own row rectangles rather than re-deriving row geometry
|
|
193
|
+
* from heights and gaps: that is the rule stated at the top of `hit-test.ts`
|
|
194
|
+
* ("layout is read, never re-derived"), and it applies here for the same
|
|
195
|
+
* reason — a drop that computed its own rows would drift from the picture the
|
|
196
|
+
* moment a row height changed, and the drift would show as a clip landing on
|
|
197
|
+
* the row above the one you aimed at.
|
|
198
|
+
*/
|
|
199
|
+
export function dropTrackIndexAt(y: number, layout: TimelineLayout): number {
|
|
200
|
+
for (const row of layout.rows) {
|
|
201
|
+
if (y >= row.y && y < row.y + row.height) return row.trackIdx
|
|
202
|
+
}
|
|
203
|
+
return -1
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve each host `PendingDrop` to the rectangle its ghost is drawn in.
|
|
208
|
+
*
|
|
209
|
+
* A `trackIndex` of -1 (or one naming a row that no longer exists — the host's
|
|
210
|
+
* list is asynchronous and the project can have changed under it) falls back to
|
|
211
|
+
* the BASE video row: the lowest-`trackIdx` row that holds video, else the
|
|
212
|
+
* first row in the layout. With no rows at all there is nowhere to draw, so the
|
|
213
|
+
* band is dropped rather than guessed at a y of 0, which would put it in the
|
|
214
|
+
* ruler.
|
|
215
|
+
*
|
|
216
|
+
* `drop.newTrack` is the one exception to "look up `trackIndex` in `layout`":
|
|
217
|
+
* there IS no row for it yet — `placeOnNewTrack` (placement.ts) hasn't run —
|
|
218
|
+
* so its rectangle is computed instead of looked up, by `newTrackRowRect`
|
|
219
|
+
* below.
|
|
220
|
+
*/
|
|
221
|
+
export function pendingDropBands(
|
|
222
|
+
pendingDrops: readonly PendingDrop[],
|
|
223
|
+
layout: TimelineLayout,
|
|
224
|
+
): PendingDropBand[] {
|
|
225
|
+
if (layout.rows.length === 0) return []
|
|
226
|
+
// `rows` is in DRAW order (highest trackIdx first), so the base video row is
|
|
227
|
+
// found by scanning for the lowest trackIdx that carries video, not by
|
|
228
|
+
// taking rows[0].
|
|
229
|
+
const videoRows = layout.rows.filter(r => r.items.some(it => it.type === 'video'))
|
|
230
|
+
const baseRow = videoRows.length > 0
|
|
231
|
+
? videoRows.reduce((lowest, r) => (r.trackIdx < lowest.trackIdx ? r : lowest))
|
|
232
|
+
: layout.rows[layout.rows.length - 1]
|
|
233
|
+
|
|
234
|
+
// Computed lazily, and only once: no drop in the list needs it unless at
|
|
235
|
+
// least one carries `newTrack`, and every `newTrack` drop shares the exact
|
|
236
|
+
// same rectangle (there is only ever one "next new track" position for a
|
|
237
|
+
// given layout).
|
|
238
|
+
let newTrackRow: { y: number; height: number } | null = null
|
|
239
|
+
const resolveNewTrackRow = (): { y: number; height: number } => {
|
|
240
|
+
if (!newTrackRow) newTrackRow = newTrackRowRect(layout, videoRows, baseRow)
|
|
241
|
+
return newTrackRow
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const bands: PendingDropBand[] = []
|
|
245
|
+
for (const drop of pendingDrops) {
|
|
246
|
+
const row = drop.newTrack
|
|
247
|
+
? resolveNewTrackRow()
|
|
248
|
+
: layout.rows.find(r => r.trackIdx === drop.trackIndex) ?? baseRow
|
|
249
|
+
bands.push({
|
|
250
|
+
start: drop.atTime,
|
|
251
|
+
end: drop.atTime + Math.max(0, drop.durationSec),
|
|
252
|
+
y: row.y,
|
|
253
|
+
height: row.height,
|
|
254
|
+
label: drop.label,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
return bands
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Where a BRAND NEW video track renders, computed against the CURRENT layout
|
|
262
|
+
* (before that track exists) — the rectangle `pendingDropBands` draws a
|
|
263
|
+
* `newTrack` ghost's band in.
|
|
264
|
+
*
|
|
265
|
+
* `placeOnNewTrack` (placement.ts) always lands a freshly-minted video track
|
|
266
|
+
* at the TOP of the video block — the highest trackIdx among video-kind
|
|
267
|
+
* tracks (its own doc comment walks through why: `orderedTrackArray`'s stable
|
|
268
|
+
* partition keeps the new track last among video-kind tracks, and "last in
|
|
269
|
+
* the video group" is the highest index in a block that is contiguous from
|
|
270
|
+
* 0). In DRAW order that is the row directly ABOVE whichever row currently
|
|
271
|
+
* holds that top-of-video-block spot — above the highest-`trackIdx` entry in
|
|
272
|
+
* `videoRows`, i.e. one row closer to the ruler than the current top video
|
|
273
|
+
* row (or than `baseRow`, when the project's only video row IS the base row).
|
|
274
|
+
* A freshly-created track always carries a video item the moment ingest
|
|
275
|
+
* finishes, so it takes the SAME height every video-kind row gets
|
|
276
|
+
* (`BASE_VISUAL_ROW_RENDER_HEIGHT_PX` — see `computeTimelineLayout`'s own
|
|
277
|
+
* height rule, "any video-kind track", not only trackIdx 0), not the shorter
|
|
278
|
+
* overlay-row height.
|
|
279
|
+
*
|
|
280
|
+
* Clamped so the band can never intrude into the ruler strip: on a project
|
|
281
|
+
* with little or no room between the ruler and the top video row (few or no
|
|
282
|
+
* overlay/caption rows above it) there isn't a full row's worth of space to
|
|
283
|
+
* insert a phantom into without the surface actually growing by one row — the
|
|
284
|
+
* canvas only grows once the real track lands. That is an inherent limit of
|
|
285
|
+
* ghosting a row that does not exist yet, not a bug: the clamp keeps the
|
|
286
|
+
* result directly adjacent to the top of the video block (never past the
|
|
287
|
+
* ruler) rather than off the top of the surface entirely.
|
|
288
|
+
*/
|
|
289
|
+
function newTrackRowRect(
|
|
290
|
+
layout: TimelineLayout,
|
|
291
|
+
videoRows: readonly VisualRowLayout[],
|
|
292
|
+
baseRow: VisualRowLayout,
|
|
293
|
+
): { y: number; height: number } {
|
|
294
|
+
const topVideoRow = videoRows.length > 0
|
|
295
|
+
? videoRows.reduce((highest, r) => (r.trackIdx > highest.trackIdx ? r : highest))
|
|
296
|
+
: baseRow
|
|
297
|
+
const height = BASE_VISUAL_ROW_RENDER_HEIGHT_PX
|
|
298
|
+
const rulerBottom = layout.ruler.y + layout.ruler.height
|
|
299
|
+
const y = Math.max(rulerBottom, topVideoRow.y - ROW_GAP_PX - height)
|
|
300
|
+
return { y, height }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const requestFrame: (cb: () => void) => number =
|
|
304
|
+
typeof requestAnimationFrame === 'function'
|
|
305
|
+
? cb => requestAnimationFrame(() => cb())
|
|
306
|
+
: cb => setTimeout(cb, 16) as unknown as number
|
|
307
|
+
|
|
308
|
+
const cancelFrame: (handle: number) => void =
|
|
309
|
+
typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : clearTimeout
|
|
310
|
+
|
|
311
|
+
/** A monotonic clock for timing the edge auto-scroll loop's frame deltas.
|
|
312
|
+
* `performance.now()` where available (real browsers, and jsdom under
|
|
313
|
+
* Vitest's fake timers, which fake it in lockstep with `requestAnimationFrame`);
|
|
314
|
+
* `Date.now()` as the only fallback. */
|
|
315
|
+
function perfNow(): number {
|
|
316
|
+
return typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Stable empty default so an omitted `snapBoundaries` doesn't churn the
|
|
320
|
+
* pointer layer's latest-props ref with a fresh array every render. */
|
|
321
|
+
const NO_SNAP_BOUNDARIES: number[] = []
|
|
322
|
+
|
|
323
|
+
/** `NO_SNAP_BOUNDARIES`' sibling, for the same reason: an omitted
|
|
324
|
+
* `pendingDrops` must not hand the ghost memo a fresh array each render. */
|
|
325
|
+
const NO_PENDING_DROPS: readonly PendingDrop[] = []
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* How tall the surface must be to reach the bottom of the pane's visible area.
|
|
329
|
+
*
|
|
330
|
+
* `surfaceOffsetTop` is the surface's top measured from the scroll CONTENT's
|
|
331
|
+
* origin (everything laid out above it: the zoom chrome, the scrubber, the
|
|
332
|
+
* paddings) — NOT its position on screen. That distinction is what makes the
|
|
333
|
+
* measurement scroll-invariant: scrolling a tall timeline moves the surface's
|
|
334
|
+
* screen position but not its offset within the content, so the fill it asks
|
|
335
|
+
* for never grows as you scroll. It is also independent of the surface's OWN
|
|
336
|
+
* height, so setting the result can never change the next measurement.
|
|
337
|
+
*
|
|
338
|
+
* `paddingBelow` is the padding under the surface in the flow (only the
|
|
339
|
+
* Timeline root's `py-3` contributes today). Pure, so it unit-tests without a
|
|
340
|
+
* layout. Clamped at 0 so an overflowing timeline never asks for a negative
|
|
341
|
+
* height (the caller's Math.max keeps the layout height there instead).
|
|
342
|
+
*/
|
|
343
|
+
export function paneFillHeight(viewportHeight: number, surfaceOffsetTop: number, paddingBelow: number): number {
|
|
344
|
+
return Math.max(0, viewportHeight - surfaceOffsetTop - paddingBelow)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Sum the `padding-bottom` of every element from `from` up to and including
|
|
348
|
+
* `to` — the padding that sits under the surface within the scroll viewport,
|
|
349
|
+
* which the fill must leave clear or it would overflow the pane by that much
|
|
350
|
+
* and show a scrollbar. Generic over the DOM chain rather than hardcoding the
|
|
351
|
+
* root's `py-3`, so a class change to the timeline column can't silently
|
|
352
|
+
* reintroduce the overflow. */
|
|
353
|
+
function paddingBelowSurface(from: HTMLElement | null, to: HTMLElement): number {
|
|
354
|
+
let total = 0
|
|
355
|
+
for (let el: HTMLElement | null = from; el; el = el.parentElement) {
|
|
356
|
+
total += parseFloat(getComputedStyle(el).paddingBottom) || 0
|
|
357
|
+
if (el === to) break
|
|
358
|
+
}
|
|
359
|
+
return total
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export default function TimelineCanvas({
|
|
363
|
+
project,
|
|
364
|
+
clock,
|
|
365
|
+
store,
|
|
366
|
+
totalDuration,
|
|
367
|
+
fps,
|
|
368
|
+
selectedIds,
|
|
369
|
+
snapBoundaries = NO_SNAP_BOUNDARIES,
|
|
370
|
+
rippleMode = false,
|
|
371
|
+
previewAxis = false,
|
|
372
|
+
onHoverScrub,
|
|
373
|
+
onSelectItem,
|
|
374
|
+
onSelectItems,
|
|
375
|
+
selectedKeyframe = null,
|
|
376
|
+
onSelectKeyframe,
|
|
377
|
+
onProjectChange,
|
|
378
|
+
onOverlayEdit,
|
|
379
|
+
onInspectClip,
|
|
380
|
+
onInspectAudio,
|
|
381
|
+
onEditCaption,
|
|
382
|
+
onFadeCurveMenu,
|
|
383
|
+
onKeyframeMenu,
|
|
384
|
+
getWaveformPeaks,
|
|
385
|
+
getFilmstrip,
|
|
386
|
+
resolveFilePath,
|
|
387
|
+
mode = 'dark',
|
|
388
|
+
onImportFilesToTimeline,
|
|
389
|
+
pendingDrops = NO_PENDING_DROPS,
|
|
390
|
+
}: TimelineCanvasProps) {
|
|
391
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
392
|
+
const contentCanvasRef = useRef<HTMLCanvasElement>(null)
|
|
393
|
+
const overlayCanvasRef = useRef<HTMLCanvasElement>(null)
|
|
394
|
+
const metricsRef = useRef<SurfaceMetrics>({ cssWidth: 0, cssHeight: 0, dpr: 1 })
|
|
395
|
+
|
|
396
|
+
// T6 — one fetch-state store for the lifetime of this mounted surface, so
|
|
397
|
+
// in-flight/resolved peaks survive across redraws (pan, zoom, selection).
|
|
398
|
+
const waveformStoreRef = useRef<WaveformPeaksStore | null>(null)
|
|
399
|
+
if (waveformStoreRef.current === null) waveformStoreRef.current = new WaveformPeaksStore()
|
|
400
|
+
|
|
401
|
+
// T7 — same lifetime rule as the waveform store, for filmstrip indexes and
|
|
402
|
+
// decoded sheet images.
|
|
403
|
+
const filmstripStoreRef = useRef<FilmstripStore | null>(null)
|
|
404
|
+
if (filmstripStoreRef.current === null) filmstripStoreRef.current = new FilmstripStore()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
// `project.captions` is in here because the caption band is part of the
|
|
408
|
+
// layout: without it a caption move or trim would change the project and
|
|
409
|
+
// repaint the OLD band, so the block would spring back under the cursor.
|
|
410
|
+
const layout = useMemo(() => computeTimelineLayout(project), [project.tracks, project.audio, project.captions])
|
|
411
|
+
|
|
412
|
+
// How far the surface grows PAST the drawn tracks to fill the empty area at
|
|
413
|
+
// the bottom of the resizable timeline pane. The tracks stay top-anchored;
|
|
414
|
+
// this only adds background below them, so a click there hits the canvas
|
|
415
|
+
// (deselect / seek / marquee) instead of dead page space. 0 until measured,
|
|
416
|
+
// and stays 0 in any host that doesn't mark a scroll viewport (see the effect
|
|
417
|
+
// below), which degrades to the pre-fill behaviour.
|
|
418
|
+
const [paneFill, setPaneFill] = useState(0)
|
|
419
|
+
const surfaceHeight = Math.max(layout.height, VISUAL_ROW_RENDER_HEIGHT_PX, paneFill)
|
|
420
|
+
|
|
421
|
+
// Latest draw inputs, readable from the imperative paint without making the
|
|
422
|
+
// paint a dependency of every effect (the ref-to-latest pattern
|
|
423
|
+
// `useTimelineZoom` uses for its wheel handler).
|
|
424
|
+
// The host's in-flight imports, resolved to rectangles against the CURRENT
|
|
425
|
+
// layout. Memoized on the layout too, not just the list: a row added or
|
|
426
|
+
// removed by an unrelated edit moves every row's y, and a ghost still drawn
|
|
427
|
+
// at the old one would float over the wrong track.
|
|
428
|
+
const pendingDropBandList = useMemo(() => pendingDropBands(pendingDrops, layout), [pendingDrops, layout])
|
|
429
|
+
|
|
430
|
+
const sceneRef = useRef({ project, layout, selectedIds, selectedKeyframe, totalDuration, mode, pendingDropBandList })
|
|
431
|
+
sceneRef.current = { project, layout, selectedIds, selectedKeyframe, totalDuration, mode, pendingDropBandList }
|
|
432
|
+
|
|
433
|
+
// The preview-axis cursor, tracked imperatively for the same reason the
|
|
434
|
+
// filmstrip hover thumb is: it moves with every mousemove, and a React state
|
|
435
|
+
// write per mouse position would re-render the caption row's hundreds of DOM
|
|
436
|
+
// nodes to move a 2px line. Read fresh at overlay-paint time.
|
|
437
|
+
const cursorTimeRef = useRef<number | null>(null)
|
|
438
|
+
|
|
439
|
+
// The snap guide, tracked the same imperative way and for the same reason:
|
|
440
|
+
// it moves during a drag, and a React state write per pointer move would
|
|
441
|
+
// re-render the whole timeline to place a 2px line. The machine emits a
|
|
442
|
+
// `snapGuide` effect only when the guide MOVES, so this is written a couple
|
|
443
|
+
// of times per gesture rather than per event.
|
|
444
|
+
const snapGuideRef = useRef<{ time: number; strength: SnapStrength } | null>(null)
|
|
445
|
+
|
|
446
|
+
// The rubber-band box, same imperative treatment: it follows the pointer, so
|
|
447
|
+
// a React state write per move would re-render the timeline to move a
|
|
448
|
+
// rectangle.
|
|
449
|
+
const marqueeRef = useRef<SurfaceRect | null>(null)
|
|
450
|
+
|
|
451
|
+
// The trim handle under the resting pointer, so the painter can light it up.
|
|
452
|
+
// Held as a ref for the usual reason, but redrawn through `requestRedraw`
|
|
453
|
+
// only when the identity changes — crossing into or out of a handle, which
|
|
454
|
+
// happens a handful of times a session, not per mousemove.
|
|
455
|
+
const hoveredHandleRef = useRef<{ itemId: string; edge: 'in' | 'out' } | null>(null)
|
|
456
|
+
|
|
457
|
+
// Hover-scrub emissions, coalesced to one per animation frame.
|
|
458
|
+
//
|
|
459
|
+
// The LINE is cheap — `requestRedraw` already folds it into one rAF. The
|
|
460
|
+
// EMISSION is not: each one asks the preview to show a different frame,
|
|
461
|
+
// which means seeking the source. A trackpad sweep delivers 60-120
|
|
462
|
+
// mousemoves a second, and on long-GOP media each seek cancels the decode
|
|
463
|
+
// still in flight, so the picture can end up never landing a frame at all
|
|
464
|
+
// (worst on un-proxied 4K HEVC, where a seek decodes up to a second of
|
|
465
|
+
// frames). One emission per frame is all a display can show anyway; the
|
|
466
|
+
// intermediate positions are dropped rather than queued, so the seek target
|
|
467
|
+
// is always the pointer's CURRENT position and never a stale backlog.
|
|
468
|
+
const hoverEmitRef = useRef<{ frame: number | null; pending: number | null }>({ frame: null, pending: null })
|
|
469
|
+
|
|
470
|
+
const dirtyRef = useRef({ content: false, overlay: false })
|
|
471
|
+
const frameRef = useRef<number | null>(null)
|
|
472
|
+
|
|
473
|
+
const paintRef = useRef<() => void>(() => {})
|
|
474
|
+
paintRef.current = () => {
|
|
475
|
+
const { cssWidth, cssHeight, dpr } = metricsRef.current
|
|
476
|
+
if (cssWidth <= 0 || cssHeight <= 0) return
|
|
477
|
+
const dirty = dirtyRef.current
|
|
478
|
+
const viewport = store.get()
|
|
479
|
+
const scene = sceneRef.current
|
|
480
|
+
|
|
481
|
+
if (dirty.content) {
|
|
482
|
+
dirty.content = false
|
|
483
|
+
const ctx = contentCanvasRef.current?.getContext('2d')
|
|
484
|
+
if (ctx) {
|
|
485
|
+
scaleContextToDpr(ctx, dpr)
|
|
486
|
+
// T6 — one query context per paint (px/second and the project id can
|
|
487
|
+
// both have moved since the last one); the store itself is what
|
|
488
|
+
// persists across paints. `onReady` re-marks content dirty and
|
|
489
|
+
// schedules a redraw once new data lands, the same way a project
|
|
490
|
+
// edit does.
|
|
491
|
+
const waveforms: WaveformSceneLookup | undefined = getWaveformPeaks
|
|
492
|
+
? {
|
|
493
|
+
clipColumns: (item, rect) => waveformStoreRef.current!.clipColumns(item, rect, {
|
|
494
|
+
projectId: scene.project.id,
|
|
495
|
+
getWaveformPeaks,
|
|
496
|
+
pxPerSecond: viewport.pxPerSecond,
|
|
497
|
+
// Lets `clipColumns` recover the clip's full on-screen span
|
|
498
|
+
// and slice its peaks to whatever sub-range `rect` actually
|
|
499
|
+
// shows — see `WaveformQueryContext.viewport`.
|
|
500
|
+
viewport,
|
|
501
|
+
onReady: () => requestRedraw('content'),
|
|
502
|
+
}),
|
|
503
|
+
audioColumns: (track, rect) => waveformStoreRef.current!.audioColumns(track, rect, {
|
|
504
|
+
projectId: scene.project.id,
|
|
505
|
+
getWaveformPeaks,
|
|
506
|
+
pxPerSecond: viewport.pxPerSecond,
|
|
507
|
+
viewport,
|
|
508
|
+
onReady: () => requestRedraw('content'),
|
|
509
|
+
}),
|
|
510
|
+
}
|
|
511
|
+
: undefined
|
|
512
|
+
// T7 — filmstrip data is shared with the overlay layer's hover thumb
|
|
513
|
+
// (see filmstrips.ts's `FilmstripQueryContext.onReady` doc), so
|
|
514
|
+
// whichever call resolves a fetch invalidates BOTH layers.
|
|
515
|
+
const filmstrips: FilmstripSceneLookup | undefined = (getFilmstrip && resolveFilePath)
|
|
516
|
+
? {
|
|
517
|
+
clipTiles: (item, rect) => filmstripStoreRef.current!.clipTiles(item, rect, {
|
|
518
|
+
projectId: scene.project.id,
|
|
519
|
+
getFilmstrip,
|
|
520
|
+
fileUrl: resolveFilePath,
|
|
521
|
+
viewport,
|
|
522
|
+
onReady: () => requestRedraw('all'),
|
|
523
|
+
}),
|
|
524
|
+
}
|
|
525
|
+
: undefined
|
|
526
|
+
drawTimelineContent(ctx, {
|
|
527
|
+
project: scene.project,
|
|
528
|
+
viewport,
|
|
529
|
+
layout: scene.layout,
|
|
530
|
+
selectedIds: scene.selectedIds,
|
|
531
|
+
selectedKeyframe: scene.selectedKeyframe,
|
|
532
|
+
hoveredHandle: hoveredHandleRef.current,
|
|
533
|
+
surfaceWidth: cssWidth,
|
|
534
|
+
surfaceHeight: cssHeight,
|
|
535
|
+
waveforms,
|
|
536
|
+
filmstrips,
|
|
537
|
+
mode: scene.mode,
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (dirty.overlay) {
|
|
543
|
+
dirty.overlay = false
|
|
544
|
+
const ctx = overlayCanvasRef.current?.getContext('2d')
|
|
545
|
+
if (ctx) {
|
|
546
|
+
scaleContextToDpr(ctx, dpr)
|
|
547
|
+
drawTimelineOverlay(ctx, {
|
|
548
|
+
viewport,
|
|
549
|
+
currentTime: clock.get(),
|
|
550
|
+
cursorTime: cursorTimeRef.current,
|
|
551
|
+
snapTime: snapGuideRef.current?.time ?? null,
|
|
552
|
+
snapStrength: snapGuideRef.current?.strength ?? null,
|
|
553
|
+
marquee: marqueeRef.current,
|
|
554
|
+
pendingDrops: scene.pendingDropBandList,
|
|
555
|
+
surfaceWidth: cssWidth,
|
|
556
|
+
surfaceHeight: cssHeight,
|
|
557
|
+
mode: scene.mode,
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const requestRedraw = useCallback((layer: 'content' | 'overlay' | 'all') => {
|
|
564
|
+
const dirty = dirtyRef.current
|
|
565
|
+
if (layer !== 'overlay') dirty.content = true
|
|
566
|
+
if (layer !== 'content') dirty.overlay = true
|
|
567
|
+
if (frameRef.current !== null) return
|
|
568
|
+
frameRef.current = requestFrame(() => {
|
|
569
|
+
frameRef.current = null
|
|
570
|
+
paintRef.current()
|
|
571
|
+
})
|
|
572
|
+
}, [])
|
|
573
|
+
|
|
574
|
+
// The latch must be reset here too, or a StrictMode remount (cleanup runs
|
|
575
|
+
// on the same instance before the effect re-fires) deadlocks every future
|
|
576
|
+
// `requestRedraw` behind a stale non-null handle.
|
|
577
|
+
useEffect(() => () => {
|
|
578
|
+
if (frameRef.current !== null) {
|
|
579
|
+
cancelFrame(frameRef.current)
|
|
580
|
+
frameRef.current = null
|
|
581
|
+
}
|
|
582
|
+
const emit = hoverEmitRef.current
|
|
583
|
+
if (emit.frame !== null) cancelFrame(emit.frame)
|
|
584
|
+
emit.frame = null
|
|
585
|
+
emit.pending = null
|
|
586
|
+
// Same StrictMode concern as `frameRef` above, for the edge auto-scroll
|
|
587
|
+
// loop's own rAF handle.
|
|
588
|
+
stopEdgeAutoScroll()
|
|
589
|
+
}, [])
|
|
590
|
+
|
|
591
|
+
// ── Surface: CSS size, DPR, backing stores ──
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
const el = containerRef.current
|
|
594
|
+
if (!el) return
|
|
595
|
+
return observeSurface(el, metrics => {
|
|
596
|
+
metricsRef.current = metrics
|
|
597
|
+
const content = contentCanvasRef.current
|
|
598
|
+
const overlay = overlayCanvasRef.current
|
|
599
|
+
if (content) syncCanvasBackingStore(content, metrics)
|
|
600
|
+
if (overlay) syncCanvasBackingStore(overlay, metrics)
|
|
601
|
+
store.set(vp => withSurfaceWidth(vp, metrics.cssWidth, sceneRef.current.totalDuration))
|
|
602
|
+
// Unconditional: writing `canvas.width` clears the canvas, and a DPR
|
|
603
|
+
// change invalidates the transform even when the CSS size is identical.
|
|
604
|
+
requestRedraw('all')
|
|
605
|
+
})
|
|
606
|
+
}, [store, requestRedraw])
|
|
607
|
+
|
|
608
|
+
// ── Fill the pane: grow the surface to reach the bottom of the timeline
|
|
609
|
+
// pane, so the empty area under the last track is live canvas ──
|
|
610
|
+
//
|
|
611
|
+
// Measured against the pane's scroll viewport (the `data-timeline-scroll`
|
|
612
|
+
// marker VideoEditor puts on it), whose height is set by the resizable pane
|
|
613
|
+
// and is INDEPENDENT of the surface's own height — so setting `paneFill` can
|
|
614
|
+
// never change what the next measurement reads, and there is no feedback loop
|
|
615
|
+
// even while the surface (and the flow below it) grows and the pane scrolls.
|
|
616
|
+
// A host that marks no viewport (the pending layout, tests) is left at 0 and
|
|
617
|
+
// keeps the pre-fill height. Re-measures on pane resize (the viewport's
|
|
618
|
+
// ResizeObserver) and whenever the layout changes (a row added above the
|
|
619
|
+
// surface shifts its top; more rows change how much space is left).
|
|
620
|
+
useEffect(() => {
|
|
621
|
+
const el = containerRef.current
|
|
622
|
+
if (!el) return
|
|
623
|
+
const scroll = el.closest('[data-timeline-scroll]') as HTMLElement | null
|
|
624
|
+
if (!scroll) return
|
|
625
|
+
const measure = () => {
|
|
626
|
+
const scrollRect = scroll.getBoundingClientRect()
|
|
627
|
+
const surfaceRect = el.getBoundingClientRect()
|
|
628
|
+
// Offset within the scroll CONTENT, not on screen: the `scrollTop` term
|
|
629
|
+
// cancels the shift `surfaceRect.top` takes on when the pane is scrolled,
|
|
630
|
+
// so a scrolled-down measurement asks for the same fill as an unscrolled
|
|
631
|
+
// one (see `paneFillHeight`).
|
|
632
|
+
const surfaceOffsetTop = surfaceRect.top - scrollRect.top + scroll.scrollTop
|
|
633
|
+
const padding = paddingBelowSurface(el.parentElement, scroll)
|
|
634
|
+
const next = Math.round(paneFillHeight(scrollRect.height, surfaceOffsetTop, padding))
|
|
635
|
+
setPaneFill(prev => (prev === next ? prev : next))
|
|
636
|
+
}
|
|
637
|
+
measure()
|
|
638
|
+
let ro: ResizeObserver | undefined
|
|
639
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
640
|
+
ro = new ResizeObserver(() => measure())
|
|
641
|
+
ro.observe(scroll)
|
|
642
|
+
}
|
|
643
|
+
const onResize = () => measure()
|
|
644
|
+
window.addEventListener('resize', onResize)
|
|
645
|
+
return () => { ro?.disconnect(); window.removeEventListener('resize', onResize) }
|
|
646
|
+
}, [layout])
|
|
647
|
+
|
|
648
|
+
// ── Turning the axis off with the pointer still over the surface takes the
|
|
649
|
+
// line and the host's override down with it: no further mousemove is
|
|
650
|
+
// coming to do it, and the preview would stay frozen on a hovered frame. ──
|
|
651
|
+
useEffect(() => {
|
|
652
|
+
if (previewAxis) return
|
|
653
|
+
const emit = hoverEmitRef.current
|
|
654
|
+
if (emit.frame !== null) cancelFrame(emit.frame)
|
|
655
|
+
emit.frame = null
|
|
656
|
+
emit.pending = null
|
|
657
|
+
if (cursorTimeRef.current === null) return
|
|
658
|
+
cursorTimeRef.current = null
|
|
659
|
+
onHoverScrub?.(null)
|
|
660
|
+
requestRedraw('overlay')
|
|
661
|
+
}, [previewAxis, onHoverScrub, requestRedraw])
|
|
662
|
+
|
|
663
|
+
// ── Viewport: pan/zoom moves everything, including the playhead ──
|
|
664
|
+
useEffect(() => store.subscribe(() => requestRedraw('all')), [store, requestRedraw])
|
|
665
|
+
|
|
666
|
+
// ── Playhead: the only per-tick subscriber, and it repaints one layer ──
|
|
667
|
+
useEffect(() => clock.subscribe(() => requestRedraw('overlay')), [clock, requestRedraw])
|
|
668
|
+
|
|
669
|
+
// ── Theme: a light/dark flip repaints BOTH layers ──
|
|
670
|
+
//
|
|
671
|
+
// `mode` is not part of the content effect below on purpose. It changes the
|
|
672
|
+
// colour of marks on the OVERLAY too — the playhead, the axis cursor, the
|
|
673
|
+
// marquee — and that layer is repainted only by the clock, the viewport or a
|
|
674
|
+
// gesture. Without this the surface would keep showing stale pixels of the
|
|
675
|
+
// previous theme until something unrelated happened to touch each layer,
|
|
676
|
+
// which for the overlay of a paused, untouched timeline is "never". The
|
|
677
|
+
// paint reads `sceneRef.current.mode`, written during the same render that
|
|
678
|
+
// schedules this effect, so the repaint always sees the NEW mode.
|
|
679
|
+
useEffect(() => { requestRedraw('all') }, [mode, requestRedraw])
|
|
680
|
+
|
|
681
|
+
// ── Content: project/selection edits ──
|
|
682
|
+
const selectionKey = selectedIds.join('\0')
|
|
683
|
+
// The selected keyframe is content too — the strip draws it filled — so a
|
|
684
|
+
// change of diamond has to repaint that layer, same as a change of item.
|
|
685
|
+
useEffect(() => { requestRedraw('content') }, [project, layout, selectionKey, selectedKeyframe, requestRedraw])
|
|
686
|
+
|
|
687
|
+
// ── Overlay: the pending-import ghosts ──
|
|
688
|
+
//
|
|
689
|
+
// The ghosts live on the overlay layer, which is repainted only by the clock,
|
|
690
|
+
// the viewport or a gesture — so without this a ghost would appear (or fail
|
|
691
|
+
// to disappear) only on the next unrelated repaint, i.e. "never" on a paused,
|
|
692
|
+
// untouched timeline. Keyed by CONTENT rather than by the array's identity,
|
|
693
|
+
// exactly like `selectionKey` above: a host that rebuilds the list each
|
|
694
|
+
// render (or passes an inline `[]`) must not schedule a repaint per render
|
|
695
|
+
// for a picture that hasn't moved.
|
|
696
|
+
const pendingDropsKey = pendingDropBandList
|
|
697
|
+
.map(b => [b.start, b.end, b.y, b.height, b.label ?? ''].join('|'))
|
|
698
|
+
.join('\0')
|
|
699
|
+
useEffect(() => { requestRedraw('overlay') }, [pendingDropsKey, requestRedraw])
|
|
700
|
+
|
|
701
|
+
// ── Duration changes re-clamp scale and scroll ──
|
|
702
|
+
useEffect(() => {
|
|
703
|
+
store.set(vp => reclampForDuration(vp, totalDuration))
|
|
704
|
+
requestRedraw('all')
|
|
705
|
+
}, [totalDuration, store, requestRedraw])
|
|
706
|
+
|
|
707
|
+
// ── Wheel: ⌘/Ctrl zoom at cursor, Alt (or horizontal) pan, plain wheel
|
|
708
|
+
// left alone so the page keeps scrolling. preventDefault only fires for
|
|
709
|
+
// intents we consume, which is why the listener must be non-passive —
|
|
710
|
+
// React's onWheel is registered passive at the root (same reason
|
|
711
|
+
// useTimelineZoom binds natively). ──
|
|
712
|
+
const wheelRef = useRef<(e: WheelEvent) => void>(() => {})
|
|
713
|
+
wheelRef.current = (e: WheelEvent) => {
|
|
714
|
+
const el = containerRef.current
|
|
715
|
+
if (!el) return
|
|
716
|
+
const intent = wheelIntent(e, e.clientX - el.getBoundingClientRect().left)
|
|
717
|
+
if (intent.kind === 'none') return
|
|
718
|
+
e.preventDefault()
|
|
719
|
+
// Don't let a wheel gesture we've already consumed bubble to the scroll
|
|
720
|
+
// container we sit inside, or to the page. (This originally existed for a
|
|
721
|
+
// sharper reason: the DOM timeline bound its own non-passive wheel
|
|
722
|
+
// listener to that container, and without this it would zoom its separate
|
|
723
|
+
// multiplier off the same gesture and widen the page under the canvas.
|
|
724
|
+
// Those rows and that listener are gone — see `useTimelineZoom` — so this
|
|
725
|
+
// is now just ordinary "we handled it" containment.)
|
|
726
|
+
e.stopPropagation()
|
|
727
|
+
store.set(vp => applyWheelIntent(vp, intent, sceneRef.current.totalDuration))
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
useEffect(() => {
|
|
731
|
+
const el = containerRef.current
|
|
732
|
+
if (!el) return
|
|
733
|
+
const onWheel = (e: WheelEvent) => wheelRef.current(e)
|
|
734
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
735
|
+
return () => el.removeEventListener('wheel', onWheel)
|
|
736
|
+
}, [])
|
|
737
|
+
|
|
738
|
+
// ── Pointer: the gesture machine's DOM shell ──
|
|
739
|
+
|
|
740
|
+
const machineRef = useRef<ReturnType<typeof createPointerMachine> | null>(null)
|
|
741
|
+
if (machineRef.current === null) machineRef.current = createPointerMachine()
|
|
742
|
+
const machine = machineRef.current
|
|
743
|
+
|
|
744
|
+
// Everything the machine's context and effects need, refreshed each render so
|
|
745
|
+
// handlers bound once on mount never read a stale project or callback.
|
|
746
|
+
const pointerRef = useRef({
|
|
747
|
+
project, layout, selectedIds, selectedKeyframe, snapBoundaries, totalDuration, fps, rippleMode, previewAxis,
|
|
748
|
+
onSelectItem, onSelectItems, onSelectKeyframe, onProjectChange, onOverlayEdit, onInspectClip, onInspectAudio, onEditCaption, onHoverScrub, onFadeCurveMenu, onKeyframeMenu,
|
|
749
|
+
onImportFilesToTimeline,
|
|
750
|
+
})
|
|
751
|
+
pointerRef.current = {
|
|
752
|
+
project, layout, selectedIds, selectedKeyframe, snapBoundaries, totalDuration, fps, rippleMode, previewAxis,
|
|
753
|
+
onSelectItem, onSelectItems, onSelectKeyframe, onProjectChange, onOverlayEdit, onInspectClip, onInspectAudio, onEditCaption, onHoverScrub, onFadeCurveMenu, onKeyframeMenu,
|
|
754
|
+
// Read by the drag handlers below, which are bound ONCE on mount — a
|
|
755
|
+
// file-drop hook read from the closure instead of from here would be the
|
|
756
|
+
// one the host passed on the very first render, forever.
|
|
757
|
+
onImportFilesToTimeline,
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const buildContext = useCallback((): PointerContext => {
|
|
761
|
+
const p = pointerRef.current
|
|
762
|
+
return {
|
|
763
|
+
project: p.project,
|
|
764
|
+
layout: p.layout,
|
|
765
|
+
viewport: store.get(),
|
|
766
|
+
selectedIds: p.selectedIds,
|
|
767
|
+
selectedKeyframe: p.selectedKeyframe,
|
|
768
|
+
snapBoundaries: p.snapBoundaries,
|
|
769
|
+
totalDuration: p.totalDuration,
|
|
770
|
+
fps: p.fps,
|
|
771
|
+
rippleMode: p.rippleMode,
|
|
772
|
+
playheadTime: clock.get(),
|
|
773
|
+
}
|
|
774
|
+
}, [store, clock])
|
|
775
|
+
|
|
776
|
+
const runEffects = useCallback((effects: PointerEffect[]) => {
|
|
777
|
+
const p = pointerRef.current
|
|
778
|
+
let edited = false
|
|
779
|
+
for (const effect of effects) {
|
|
780
|
+
switch (effect.type) {
|
|
781
|
+
case 'seek': clock.set(effect.time); break
|
|
782
|
+
case 'select': p.onSelectItem?.(effect.id, effect.additive); break
|
|
783
|
+
case 'selectKeyframe': p.onSelectKeyframe?.({ itemId: effect.itemId, t: effect.t }); break
|
|
784
|
+
case 'projectChange': p.onProjectChange?.(effect.project); edited = true; break
|
|
785
|
+
case 'commit': p.onOverlayEdit?.(effect.project); break
|
|
786
|
+
case 'inspect': (effect.target === 'visual' ? p.onInspectClip : p.onInspectAudio)?.(effect.id); break
|
|
787
|
+
case 'editCaption': p.onEditCaption?.(effect.id); break
|
|
788
|
+
// Cursor is written straight to the node: an affordance that changes on
|
|
789
|
+
// every hover must not cost a React render.
|
|
790
|
+
case 'cursor': if (containerRef.current) containerRef.current.style.cursor = effect.cursor; break
|
|
791
|
+
// Overlay-only: the guide is gesture feedback, not content, so putting
|
|
792
|
+
// it up or taking it down never costs a filmstrip repaint.
|
|
793
|
+
case 'snapGuide':
|
|
794
|
+
snapGuideRef.current = effect.time === null || effect.strength === null
|
|
795
|
+
? null
|
|
796
|
+
: { time: effect.time, strength: effect.strength }
|
|
797
|
+
requestRedraw('overlay')
|
|
798
|
+
break
|
|
799
|
+
// Overlay-only for the same reason as the guide: the box follows the
|
|
800
|
+
// pointer, and repainting clips and filmstrips behind it sixty times a
|
|
801
|
+
// second to move a rectangle would be absurd.
|
|
802
|
+
case 'marquee':
|
|
803
|
+
marqueeRef.current = effect.rect
|
|
804
|
+
requestRedraw('overlay')
|
|
805
|
+
break
|
|
806
|
+
case 'selectMany':
|
|
807
|
+
if (p.onSelectItems) {
|
|
808
|
+
p.onSelectItems(effect.ids, effect.additive)
|
|
809
|
+
} else {
|
|
810
|
+
// No bulk handler: replay as singles. The first is non-additive
|
|
811
|
+
// unless the marquee itself was additive (so it replaces the old
|
|
812
|
+
// selection), and every one after it extends what the first set.
|
|
813
|
+
if (!effect.additive && effect.ids.length === 0) p.onSelectItem?.(null, false)
|
|
814
|
+
effect.ids.forEach((id, i) => p.onSelectItem?.(id, effect.additive || i > 0))
|
|
815
|
+
}
|
|
816
|
+
break
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// The edit reaches the surface as a new `project` prop, which schedules a
|
|
820
|
+
// redraw on its own — but only once the host echoes it back. Marking the
|
|
821
|
+
// content dirty here keeps a drag responsive under a host that defers.
|
|
822
|
+
if (edited) requestRedraw('content')
|
|
823
|
+
}, [clock, requestRedraw])
|
|
824
|
+
|
|
825
|
+
// Document-level move/up, live only for the duration of a gesture (the DOM
|
|
826
|
+
// drag hook's pattern) so ordinary mouse movement over the page costs nothing.
|
|
827
|
+
const releaseGestureRef = useRef<(() => void) | null>(null)
|
|
828
|
+
|
|
829
|
+
// The surface rect, frozen for the duration of a gesture — see
|
|
830
|
+
// `surfacePoint`'s comment for why. Null while idle.
|
|
831
|
+
const gestureRectRef = useRef<DOMRect | null>(null)
|
|
832
|
+
|
|
833
|
+
// ── Edge auto-scroll ──
|
|
834
|
+
//
|
|
835
|
+
// The latest surface-space point and modifiers a real pointermove reported
|
|
836
|
+
// during a drag. The rAF loop below re-feeds THIS SAME point back into the
|
|
837
|
+
// machine after each pan, rather than reading a fresh one — the pointer
|
|
838
|
+
// itself hasn't moved, only the view under it has, so re-dispatching the
|
|
839
|
+
// unchanged point against the panned viewport is exactly what advances the
|
|
840
|
+
// dragged item's time (see `dispatchPointerMove`). Null outside a gesture.
|
|
841
|
+
const lastDragPointRef = useRef<{ point: Point; modifiers: Modifiers } | null>(null)
|
|
842
|
+
// The loop's own rAF handle, and the timestamp its last tick ran at (for a
|
|
843
|
+
// framerate-independent pan). Both null while the loop isn't running.
|
|
844
|
+
const edgeScrollFrameRef = useRef<number | null>(null)
|
|
845
|
+
const edgeScrollLastTimeRef = useRef<number | null>(null)
|
|
846
|
+
|
|
847
|
+
const handlersRef = useRef({
|
|
848
|
+
down: (_e: MouseEvent) => {},
|
|
849
|
+
hover: (_e: MouseEvent) => {},
|
|
850
|
+
move: (_e: MouseEvent) => {},
|
|
851
|
+
up: (_e: MouseEvent) => {},
|
|
852
|
+
doubleClick: (_e: MouseEvent) => {},
|
|
853
|
+
leave: (_e: MouseEvent) => {},
|
|
854
|
+
contextMenu: (_e: MouseEvent) => {},
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
function surfacePoint(e: MouseEvent): Point | null {
|
|
858
|
+
const el = containerRef.current
|
|
859
|
+
if (!el) return null
|
|
860
|
+
// A gesture's own live edit can resize the surface and shift its rect mid-
|
|
861
|
+
// drag (a cross-track move adds/removes a row under the moving cursor); the
|
|
862
|
+
// press-time rect is that gesture's fixed frame of reference, matching the
|
|
863
|
+
// DOM drag hooks' raw-delta math. Idle reads (hover, no gesture) stay live.
|
|
864
|
+
const rect = gestureRectRef.current ?? el.getBoundingClientRect()
|
|
865
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function modifiersOf(e: MouseEvent): Modifiers {
|
|
869
|
+
return { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey, ctrl: e.ctrlKey }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── Trim-handle hover ──
|
|
873
|
+
//
|
|
874
|
+
// A second hit-test per hover event, on top of the one the machine does
|
|
875
|
+
// internally. It is gated on there BEING a selection, because handles are
|
|
876
|
+
// only drawn on selected items: with nothing selected — the common state
|
|
877
|
+
// while just moving the pointer around — this costs a length check and
|
|
878
|
+
// returns.
|
|
879
|
+
//
|
|
880
|
+
// The alternative was another remembered value in the machine and an effect
|
|
881
|
+
// per hover; a hit-test over the visible rows is cheaper than that, and it
|
|
882
|
+
// keeps the machine about gestures rather than about paint.
|
|
883
|
+
|
|
884
|
+
function hoveredHandleAt(point: Point): { itemId: string; edge: 'in' | 'out' } | null {
|
|
885
|
+
const p = pointerRef.current
|
|
886
|
+
if (p.selectedIds.length === 0) return null
|
|
887
|
+
// `selectedIds` also lets a keyframe diamond's own small zone take
|
|
888
|
+
// precedence here, same as it does in the machine's own hit-test — a
|
|
889
|
+
// trim handle must not light up underneath a diamond that would win the
|
|
890
|
+
// actual press.
|
|
891
|
+
const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds })
|
|
892
|
+
if (!isEdgeHit(hit) || hit.itemId === undefined || hit.edge === undefined) return null
|
|
893
|
+
return p.selectedIds.includes(hit.itemId) ? { itemId: hit.itemId, edge: hit.edge } : null
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function updateHoveredHandle(point: Point) {
|
|
897
|
+
const next = hoveredHandleAt(point)
|
|
898
|
+
const prev = hoveredHandleRef.current
|
|
899
|
+
if (prev?.itemId === next?.itemId && prev?.edge === next?.edge) return
|
|
900
|
+
hoveredHandleRef.current = next
|
|
901
|
+
requestRedraw('content')
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function clearHoveredHandle() {
|
|
905
|
+
if (hoveredHandleRef.current === null) return
|
|
906
|
+
hoveredHandleRef.current = null
|
|
907
|
+
requestRedraw('content')
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// ── Preview axis: the cursor line, and the frame it asks the host to show ──
|
|
911
|
+
//
|
|
912
|
+
// Both halves are driven from the same place so the line and the previewed
|
|
913
|
+
// frame can never disagree. Time is taken raw from the x position — no
|
|
914
|
+
// snapping, because a hover affordance that jumped to clip boundaries would
|
|
915
|
+
// preview a frame other than the one the line is drawn at.
|
|
916
|
+
|
|
917
|
+
function updateAxisCursor(point: Point) {
|
|
918
|
+
const p = pointerRef.current
|
|
919
|
+
if (!p.previewAxis) return
|
|
920
|
+
const t = Math.max(0, Math.min(p.totalDuration, xToTime(point.x, store.get())))
|
|
921
|
+
if (cursorTimeRef.current === t) return
|
|
922
|
+
cursorTimeRef.current = t
|
|
923
|
+
requestRedraw('overlay')
|
|
924
|
+
// The line tracks the pointer exactly; the frame request is rate-limited.
|
|
925
|
+
const emit = hoverEmitRef.current
|
|
926
|
+
emit.pending = t
|
|
927
|
+
if (emit.frame !== null) return
|
|
928
|
+
emit.frame = requestFrame(() => {
|
|
929
|
+
emit.frame = null
|
|
930
|
+
const next = emit.pending
|
|
931
|
+
emit.pending = null
|
|
932
|
+
if (next !== null) pointerRef.current.onHoverScrub?.(next)
|
|
933
|
+
})
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Drop any frame request that hasn't fired yet, so a release can't be
|
|
937
|
+
* overtaken by a stale position arriving one frame later. */
|
|
938
|
+
function cancelPendingHoverEmit() {
|
|
939
|
+
const emit = hoverEmitRef.current
|
|
940
|
+
if (emit.frame !== null) cancelFrame(emit.frame)
|
|
941
|
+
emit.frame = null
|
|
942
|
+
emit.pending = null
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Guarded on the ref, so the paths that call this speculatively (every
|
|
946
|
+
// pointer-leave, every press, mount with the axis already off) stay silent
|
|
947
|
+
// when there was no cursor up to take down.
|
|
948
|
+
function clearAxisCursor() {
|
|
949
|
+
cancelPendingHoverEmit()
|
|
950
|
+
if (cursorTimeRef.current === null) return
|
|
951
|
+
cursorTimeRef.current = null
|
|
952
|
+
// Released synchronously, never deferred: the preview must be handed back
|
|
953
|
+
// to the playhead the instant the pointer leaves or a gesture starts.
|
|
954
|
+
pointerRef.current.onHoverScrub?.(null)
|
|
955
|
+
requestRedraw('overlay')
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ── Edge auto-scroll: pan the view while a drag holds near either edge ──
|
|
959
|
+
//
|
|
960
|
+
// Standard NLE behaviour: drag an item/handle past the visible edge and the
|
|
961
|
+
// view pans to follow, rather than trapping the gesture at whatever was on
|
|
962
|
+
// screen when the drag started. Only gestures where "the pointer is
|
|
963
|
+
// captured and following makes sense" qualify — every `dragging` state
|
|
964
|
+
// EXCEPT `scrub` (the ruler already owns the playhead directly; panning
|
|
965
|
+
// underneath it while it drags would fight the seek instead of extending
|
|
966
|
+
// it). Marquee selection is included: dragging the box out past the edge to
|
|
967
|
+
// catch items further along the timeline is the same affordance.
|
|
968
|
+
|
|
969
|
+
function dispatchPointerMove(point: Point, modifiers: Modifiers) {
|
|
970
|
+
runEffects(machine.dispatch({ type: 'pointerMove', point, modifiers, ctx: buildContext() }))
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function stopEdgeAutoScroll() {
|
|
974
|
+
if (edgeScrollFrameRef.current !== null) {
|
|
975
|
+
cancelFrame(edgeScrollFrameRef.current)
|
|
976
|
+
edgeScrollFrameRef.current = null
|
|
977
|
+
}
|
|
978
|
+
edgeScrollLastTimeRef.current = null
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function inEdgeZone(pointerX: number, surfaceWidth: number): boolean {
|
|
982
|
+
return pointerX < EDGE_SCROLL_ZONE_PX || pointerX > surfaceWidth - EDGE_SCROLL_ZONE_PX
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function edgeAutoScrollTick() {
|
|
986
|
+
edgeScrollFrameRef.current = null
|
|
987
|
+
|
|
988
|
+
const state = machine.state
|
|
989
|
+
if (state.kind !== 'dragging' || state.gesture === 'scrub') { stopEdgeAutoScroll(); return }
|
|
990
|
+
const drag = lastDragPointRef.current
|
|
991
|
+
const rect = gestureRectRef.current
|
|
992
|
+
if (!drag || !rect || rect.width <= 0) { stopEdgeAutoScroll(); return }
|
|
993
|
+
if (!inEdgeZone(drag.point.x, rect.width)) { stopEdgeAutoScroll(); return }
|
|
994
|
+
|
|
995
|
+
const now = perfNow()
|
|
996
|
+
const last = edgeScrollLastTimeRef.current
|
|
997
|
+
edgeScrollLastTimeRef.current = now
|
|
998
|
+
// The first tick has no prior timestamp to diff against; skip panning
|
|
999
|
+
// this frame (a 0-length delta would pan nothing anyway) and let the next
|
|
1000
|
+
// one carry a real elapsed time, so the very first frame after entering
|
|
1001
|
+
// the zone doesn't jump by a guessed duration.
|
|
1002
|
+
if (last !== null) {
|
|
1003
|
+
const dt = Math.min(0.1, (now - last) / 1000)
|
|
1004
|
+
const viewport = store.get()
|
|
1005
|
+
const delta = edgeScrollDelta(
|
|
1006
|
+
drag.point.x, rect.width, viewport.pxPerSecond, dt,
|
|
1007
|
+
EDGE_SCROLL_ZONE_PX, EDGE_SCROLL_MAX_PX_PER_SEC, EDGE_SCROLL_RAMP_PX,
|
|
1008
|
+
)
|
|
1009
|
+
if (delta !== 0) {
|
|
1010
|
+
let hitClamp = false
|
|
1011
|
+
store.set(vp => {
|
|
1012
|
+
const nextScroll = clampScrollSeconds(vp.scrollSeconds + delta, vp, sceneRef.current.totalDuration)
|
|
1013
|
+
if (nextScroll === vp.scrollSeconds) { hitClamp = true; return vp }
|
|
1014
|
+
return { ...vp, scrollSeconds: nextScroll }
|
|
1015
|
+
})
|
|
1016
|
+
if (hitClamp) { stopEdgeAutoScroll(); return }
|
|
1017
|
+
// Re-feed the SAME screen point now that scrollSeconds has moved
|
|
1018
|
+
// under it — the store's own `subscribe` (wired above) already
|
|
1019
|
+
// requests the repaint this pan needs.
|
|
1020
|
+
dispatchPointerMove(drag.point, drag.modifiers)
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
edgeScrollFrameRef.current = requestFrame(edgeAutoScrollTick)
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Called on every real pointermove during a gesture. Starts the loop the
|
|
1028
|
+
* first time the pointer enters an edge zone; leaves it running otherwise
|
|
1029
|
+
* (the loop re-reads `lastDragPointRef` itself each tick, so a pointer that
|
|
1030
|
+
* keeps moving within the zone doesn't need to restart anything, and one
|
|
1031
|
+
* that leaves the zone is caught on the loop's own next tick). */
|
|
1032
|
+
function updateEdgeAutoScroll() {
|
|
1033
|
+
const state = machine.state
|
|
1034
|
+
if (state.kind !== 'dragging' || state.gesture === 'scrub') { stopEdgeAutoScroll(); return }
|
|
1035
|
+
const drag = lastDragPointRef.current
|
|
1036
|
+
const rect = gestureRectRef.current
|
|
1037
|
+
if (!drag || !rect || rect.width <= 0 || !inEdgeZone(drag.point.x, rect.width)) return
|
|
1038
|
+
if (edgeScrollFrameRef.current !== null) return // already running
|
|
1039
|
+
edgeScrollLastTimeRef.current = null
|
|
1040
|
+
edgeScrollFrameRef.current = requestFrame(edgeAutoScrollTick)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
handlersRef.current = {
|
|
1044
|
+
down(e) {
|
|
1045
|
+
if (e.button !== 0) return
|
|
1046
|
+
// A stale gesture whose mouseup never fired (focus loss, etc.) must not
|
|
1047
|
+
// leave its frozen rect and listeners behind for this new one.
|
|
1048
|
+
releaseGestureRef.current?.()
|
|
1049
|
+
gestureRectRef.current = containerRef.current?.getBoundingClientRect() ?? null
|
|
1050
|
+
const point = surfacePoint(e)
|
|
1051
|
+
if (!point) return
|
|
1052
|
+
// A gesture is starting — no axis cursor during a drag/trim. The gesture
|
|
1053
|
+
// itself owns the playhead from here, and a click seeks, so leaving the
|
|
1054
|
+
// cursor up would draw a second line the drag never moves.
|
|
1055
|
+
clearAxisCursor()
|
|
1056
|
+
// Suppress the native text-selection drag; the surface is `select-none`
|
|
1057
|
+
// but a press-and-drag still starts one in some browsers. That
|
|
1058
|
+
// preventDefault also suppresses the focus a plain click would give this
|
|
1059
|
+
// container, which Timeline's own Delete/Enter bindings depend on
|
|
1060
|
+
// (they only fire with focus inside Timeline's root — see Timeline.tsx)
|
|
1061
|
+
// — so focus it explicitly.
|
|
1062
|
+
containerRef.current?.focus({ preventScroll: true })
|
|
1063
|
+
e.preventDefault()
|
|
1064
|
+
runEffects(machine.dispatch({ type: 'pointerDown', point, modifiers: modifiersOf(e), ctx: buildContext() }))
|
|
1065
|
+
|
|
1066
|
+
const onMove = (ev: MouseEvent) => handlersRef.current.move(ev)
|
|
1067
|
+
const onUp = (ev: MouseEvent) => handlersRef.current.up(ev)
|
|
1068
|
+
document.addEventListener('mousemove', onMove)
|
|
1069
|
+
document.addEventListener('mouseup', onUp)
|
|
1070
|
+
releaseGestureRef.current = () => {
|
|
1071
|
+
document.removeEventListener('mousemove', onMove)
|
|
1072
|
+
document.removeEventListener('mouseup', onUp)
|
|
1073
|
+
gestureRectRef.current = null
|
|
1074
|
+
releaseGestureRef.current = null
|
|
1075
|
+
// Every path that ends a gesture — release, a stale gesture's next
|
|
1076
|
+
// press, or unmount — runs through here, so this is the one place
|
|
1077
|
+
// edge auto-scroll needs to be torn down.
|
|
1078
|
+
stopEdgeAutoScroll()
|
|
1079
|
+
lastDragPointRef.current = null
|
|
1080
|
+
}
|
|
1081
|
+
},
|
|
1082
|
+
// Hover only updates the cursor, and only while no gesture is running — the
|
|
1083
|
+
// document listener owns movement once a press is down, so without this
|
|
1084
|
+
// guard every mid-drag move would be dispatched twice.
|
|
1085
|
+
hover(e) {
|
|
1086
|
+
if (machine.state.kind !== 'idle') return
|
|
1087
|
+
const point = surfacePoint(e)
|
|
1088
|
+
if (!point) return
|
|
1089
|
+
runEffects(machine.dispatch({ type: 'pointerMove', point, modifiers: modifiersOf(e), ctx: buildContext() }))
|
|
1090
|
+
updateHoveredHandle(point)
|
|
1091
|
+
updateAxisCursor(point)
|
|
1092
|
+
},
|
|
1093
|
+
move(e) {
|
|
1094
|
+
const point = surfacePoint(e)
|
|
1095
|
+
if (!point) return
|
|
1096
|
+
const modifiers = modifiersOf(e)
|
|
1097
|
+
// Latched for edge auto-scroll: its rAF loop re-dispatches THIS point
|
|
1098
|
+
// once scrollSeconds pans under it, rather than reading a fresh one —
|
|
1099
|
+
// see `lastDragPointRef`'s doc.
|
|
1100
|
+
lastDragPointRef.current = { point, modifiers }
|
|
1101
|
+
dispatchPointerMove(point, modifiers)
|
|
1102
|
+
updateEdgeAutoScroll()
|
|
1103
|
+
},
|
|
1104
|
+
up(e) {
|
|
1105
|
+
// Point first, then release — the point must still see this gesture's
|
|
1106
|
+
// frozen rect, not the live one the teardown below reverts to.
|
|
1107
|
+
const point = surfacePoint(e)
|
|
1108
|
+
releaseGestureRef.current?.()
|
|
1109
|
+
if (!point) { runEffects(machine.dispatch({ type: 'cancel' })); return }
|
|
1110
|
+
runEffects(machine.dispatch({ type: 'pointerUp', point, modifiers: modifiersOf(e), ctx: buildContext() }))
|
|
1111
|
+
},
|
|
1112
|
+
doubleClick(e) {
|
|
1113
|
+
const point = surfacePoint(e)
|
|
1114
|
+
if (!point) return
|
|
1115
|
+
e.preventDefault()
|
|
1116
|
+
runEffects(machine.dispatch({ type: 'doubleClick', point, modifiers: modifiersOf(e), ctx: buildContext() }))
|
|
1117
|
+
},
|
|
1118
|
+
// The pointer left the surface entirely; no more `mousemove` events will
|
|
1119
|
+
// arrive to naturally age the cursor out, so drop it explicitly.
|
|
1120
|
+
leave() {
|
|
1121
|
+
clearAxisCursor()
|
|
1122
|
+
// A gesture owns the highlight until it ends: during a trim the pointer
|
|
1123
|
+
// routinely leaves the surface, and dropping the lit handle then would
|
|
1124
|
+
// un-light the very edge being dragged.
|
|
1125
|
+
if (machine.state.kind === 'idle') clearHoveredHandle()
|
|
1126
|
+
},
|
|
1127
|
+
// Right-click a fade grip → the fade-shape picker (Vegas' own gesture), OR
|
|
1128
|
+
// a keyframe diamond → its own easing/remove picker (SP9b T3.3, the same
|
|
1129
|
+
// shape of popup — see `onKeyframeMenu`'s doc). Anywhere else on the
|
|
1130
|
+
// surface, this is a no-op and the browser's normal context menu shows —
|
|
1131
|
+
// only one of those two hits calls `preventDefault`. Runs its OWN
|
|
1132
|
+
// hit-test rather than going through the pointer machine: a right-click
|
|
1133
|
+
// is not a gesture (no drag, no press/release pair), and the machine's
|
|
1134
|
+
// vocabulary has nothing for it.
|
|
1135
|
+
contextMenu(e) {
|
|
1136
|
+
const p = pointerRef.current
|
|
1137
|
+
if (!p.onFadeCurveMenu && !p.onKeyframeMenu) return
|
|
1138
|
+
const point = surfacePoint(e)
|
|
1139
|
+
if (!point) return
|
|
1140
|
+
const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds })
|
|
1141
|
+
if (p.onFadeCurveMenu && hit.kind === 'audio-fade' && hit.itemId !== undefined && hit.side) {
|
|
1142
|
+
e.preventDefault()
|
|
1143
|
+
p.onFadeCurveMenu({ trackId: hit.itemId, side: hit.side, x: e.clientX, y: e.clientY })
|
|
1144
|
+
return
|
|
1145
|
+
}
|
|
1146
|
+
if (p.onKeyframeMenu && hit.kind === 'keyframe' && hit.itemId !== undefined && hit.kfT !== undefined && hit.item) {
|
|
1147
|
+
e.preventDefault()
|
|
1148
|
+
const times = keyframeUnionTimes(hit.item)
|
|
1149
|
+
const isLast = times.length > 0 && hit.kfT === times[times.length - 1]
|
|
1150
|
+
const props = (hit.item.keyframes ?? [])
|
|
1151
|
+
.filter(track => track.points.some(pt => pt.t === hit.kfT))
|
|
1152
|
+
.map(track => track.prop)
|
|
1153
|
+
p.onKeyframeMenu({ itemId: hit.itemId, t: hit.kfT, props, isLast, x: e.clientX, y: e.clientY })
|
|
1154
|
+
}
|
|
1155
|
+
},
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
useEffect(() => {
|
|
1159
|
+
const el = containerRef.current
|
|
1160
|
+
if (!el) return
|
|
1161
|
+
const onDown = (e: MouseEvent) => handlersRef.current.down(e)
|
|
1162
|
+
const onHover = (e: MouseEvent) => handlersRef.current.hover(e)
|
|
1163
|
+
const onDoubleClick = (e: MouseEvent) => handlersRef.current.doubleClick(e)
|
|
1164
|
+
const onLeave = (e: MouseEvent) => handlersRef.current.leave(e)
|
|
1165
|
+
const onContextMenu = (e: MouseEvent) => handlersRef.current.contextMenu(e)
|
|
1166
|
+
el.addEventListener('mousedown', onDown)
|
|
1167
|
+
el.addEventListener('mousemove', onHover)
|
|
1168
|
+
el.addEventListener('dblclick', onDoubleClick)
|
|
1169
|
+
el.addEventListener('contextmenu', onContextMenu)
|
|
1170
|
+
el.addEventListener('mouseleave', onLeave)
|
|
1171
|
+
return () => {
|
|
1172
|
+
el.removeEventListener('mousedown', onDown)
|
|
1173
|
+
el.removeEventListener('mousemove', onHover)
|
|
1174
|
+
el.removeEventListener('dblclick', onDoubleClick)
|
|
1175
|
+
el.removeEventListener('contextmenu', onContextMenu)
|
|
1176
|
+
el.removeEventListener('mouseleave', onLeave)
|
|
1177
|
+
releaseGestureRef.current?.()
|
|
1178
|
+
}
|
|
1179
|
+
}, [])
|
|
1180
|
+
|
|
1181
|
+
// ── Drops onto the surface: the footage bin, and OS files ──
|
|
1182
|
+
//
|
|
1183
|
+
// Two drags land here and neither is a pointer gesture — the browser owns
|
|
1184
|
+
// the drag, there is no mousedown/up pair on this surface — so both stay out
|
|
1185
|
+
// of the pointer machine.
|
|
1186
|
+
//
|
|
1187
|
+
// A FOOTAGE-BIN drag (our own `FOOTAGE_DND_MIME`) carries everything needed
|
|
1188
|
+
// to place a clip, so it commits here, straight through the SAME
|
|
1189
|
+
// discrete-edit pair every other discrete timeline edit uses:
|
|
1190
|
+
// `onProjectChange` applies it live, `onOverlayEdit` persists it as one undo
|
|
1191
|
+
// step (mirroring the machine's own `projectChange`+`commit` effects in
|
|
1192
|
+
// `runEffects`, and Timeline's ripple-delete keymap). WHERE it lands is not
|
|
1193
|
+
// decided here: both drop paths hand the drop time and the row released over
|
|
1194
|
+
// to `placeDroppedClip` (placement.ts), which owns the one rule — "where you
|
|
1195
|
+
// dropped it, without stomping existing footage". (It used to pin every bin
|
|
1196
|
+
// drop to the main video track regardless of the row under the cursor; a
|
|
1197
|
+
// drop onto an occupied span then silently overlapped whatever was there.)
|
|
1198
|
+
//
|
|
1199
|
+
// An OS-FILE drag cannot be placed here at all: a `File` has no duration, no
|
|
1200
|
+
// proxy and no host-resolvable path until the host has probed and ingested
|
|
1201
|
+
// it. So that path only REPORTS the drop — the files, the time, the row —
|
|
1202
|
+
// through `onImportFilesToTimeline`, and the host commits the clip when its
|
|
1203
|
+
// import lands. The hook's PRESENCE is also the feature detection: without
|
|
1204
|
+
// it we never `preventDefault` an OS-file drag, so the browser keeps its own
|
|
1205
|
+
// handling and the gesture is completely inert (`dataTransfer.getData()`
|
|
1206
|
+
// returns "" during `dragover` for security, which is why the accept test
|
|
1207
|
+
// reads `types` rather than the data itself — both paths depend on that).
|
|
1208
|
+
//
|
|
1209
|
+
// The insertion indicator is the overlay's own cursor line
|
|
1210
|
+
// (`drawTimelineOverlay`'s `cursorTime`) reused via `cursorTimeRef` — no new
|
|
1211
|
+
// draw plumbing — and is taken down on `dragleave`/`drop`. It is written
|
|
1212
|
+
// straight to the ref (never through `clearAxisCursor`) so a drag never fires
|
|
1213
|
+
// the host's `onHoverScrub` preview seek.
|
|
1214
|
+
const dragHandlersRef = useRef({
|
|
1215
|
+
over: (_e: DragEvent) => {},
|
|
1216
|
+
leave: (_e: DragEvent) => {},
|
|
1217
|
+
drop: (_e: DragEvent) => {},
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
/** Where in the timeline a drop at client-x `clientX` landed, clamped to the
|
|
1221
|
+
* project's own span. Shared by both drop paths so a bin clip and a
|
|
1222
|
+
* filesystem file dropped at the same pixel land at the same second. */
|
|
1223
|
+
function dropTimeAt(clientX: number, rect: DOMRect): number {
|
|
1224
|
+
return Math.max(0, Math.min(
|
|
1225
|
+
pointerRef.current.totalDuration,
|
|
1226
|
+
xToTime(clientX - rect.left, store.get()),
|
|
1227
|
+
))
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** The snap inputs `placeDroppedClip` takes: every boundary a gesture would
|
|
1231
|
+
* magnetize to, plus the playhead, and the magnet's radius expressed in
|
|
1232
|
+
* SECONDS.
|
|
1233
|
+
*
|
|
1234
|
+
* Pixels are the unit of feel (see snap.ts's own module comment): a magnet
|
|
1235
|
+
* has to cover the same distance on screen whether the timeline shows ten
|
|
1236
|
+
* seconds or ten minutes, so the radius is a pixel count divided by the
|
|
1237
|
+
* current scale on every call rather than a fixed number of seconds. A
|
|
1238
|
+
* non-positive `pxPerSecond` (a viewport not yet measured) would divide to
|
|
1239
|
+
* Infinity and magnetize the drop to the nearest boundary anywhere on the
|
|
1240
|
+
* timeline, so it disables snapping instead. */
|
|
1241
|
+
function dropSnapInputs(): { snapTimes: number[]; snapToleranceSec: number } {
|
|
1242
|
+
const p = pointerRef.current
|
|
1243
|
+
const pxPerSecond = store.get().pxPerSecond
|
|
1244
|
+
return {
|
|
1245
|
+
snapTimes: [...(p.snapBoundaries ?? []), clock.get()],
|
|
1246
|
+
snapToleranceSec: pxPerSecond > 0 ? DEFAULT_SNAP_CONFIG.attractPx / pxPerSecond : 0,
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
dragHandlersRef.current = {
|
|
1251
|
+
over(e) {
|
|
1252
|
+
const dt = e.dataTransfer
|
|
1253
|
+
if (!dt) return
|
|
1254
|
+
// EITHER our own bin MIME, or — only when the host gave us somewhere to
|
|
1255
|
+
// send them — an OS-file drag. `getData()` is unreadable during
|
|
1256
|
+
// `dragover`, so this can only test `types`.
|
|
1257
|
+
const isFootage = dt.types?.includes(FOOTAGE_DND_MIME)
|
|
1258
|
+
const isFiles = !!pointerRef.current.onImportFilesToTimeline && dt.types?.includes('Files')
|
|
1259
|
+
if (!isFootage && !isFiles) return
|
|
1260
|
+
// Without preventDefault the browser never fires `drop`; `copy` shows the
|
|
1261
|
+
// right affordance (a drop adds a placement, it doesn't move the source).
|
|
1262
|
+
e.preventDefault()
|
|
1263
|
+
dt.dropEffect = 'copy'
|
|
1264
|
+
const el = containerRef.current
|
|
1265
|
+
if (!el) return
|
|
1266
|
+
const t = xToTime(e.clientX - el.getBoundingClientRect().left, store.get())
|
|
1267
|
+
if (cursorTimeRef.current === t) return
|
|
1268
|
+
cursorTimeRef.current = t
|
|
1269
|
+
requestRedraw('overlay')
|
|
1270
|
+
},
|
|
1271
|
+
leave() {
|
|
1272
|
+
if (cursorTimeRef.current === null) return
|
|
1273
|
+
cursorTimeRef.current = null
|
|
1274
|
+
requestRedraw('overlay')
|
|
1275
|
+
},
|
|
1276
|
+
drop(e) {
|
|
1277
|
+
const p = pointerRef.current
|
|
1278
|
+
const el = containerRef.current
|
|
1279
|
+
// Which drag this is — decided by what the DataTransfer actually
|
|
1280
|
+
// CARRIES, not by what `dragover` accepted a moment ago: a drop can
|
|
1281
|
+
// arrive from a drag that started outside this surface entirely.
|
|
1282
|
+
const raw = e.dataTransfer?.getData(FOOTAGE_DND_MIME)
|
|
1283
|
+
const files = e.dataTransfer?.files
|
|
1284
|
+
|
|
1285
|
+
if (raw) {
|
|
1286
|
+
e.preventDefault()
|
|
1287
|
+
// Take the indicator down whatever happens below.
|
|
1288
|
+
if (cursorTimeRef.current !== null) { cursorTimeRef.current = null; requestRedraw('overlay') }
|
|
1289
|
+
|
|
1290
|
+
let payload: FootageDropPayload
|
|
1291
|
+
try {
|
|
1292
|
+
payload = JSON.parse(raw) as FootageDropPayload
|
|
1293
|
+
} catch {
|
|
1294
|
+
return
|
|
1295
|
+
}
|
|
1296
|
+
if (
|
|
1297
|
+
!payload ||
|
|
1298
|
+
typeof payload.src !== 'string' ||
|
|
1299
|
+
typeof payload.sourceDuration !== 'number' ||
|
|
1300
|
+
!Number.isFinite(payload.sourceDuration) ||
|
|
1301
|
+
payload.sourceDuration <= 0
|
|
1302
|
+
) return
|
|
1303
|
+
|
|
1304
|
+
if (!p.onProjectChange && !p.onOverlayEdit) return
|
|
1305
|
+
if (!el) return
|
|
1306
|
+
|
|
1307
|
+
const rect = el.getBoundingClientRect()
|
|
1308
|
+
const placed = placeDroppedClip(p.project, {
|
|
1309
|
+
atTime: dropTimeAt(e.clientX, rect),
|
|
1310
|
+
preferredTrackIndex: dropTrackIndexAt(e.clientY - rect.top, p.layout),
|
|
1311
|
+
clip: payload,
|
|
1312
|
+
ripple: p.rippleMode,
|
|
1313
|
+
...dropSnapInputs(),
|
|
1314
|
+
})
|
|
1315
|
+
// `placeDroppedClip` returns the input project BY REFERENCE when it
|
|
1316
|
+
// placed nothing (an unplaceable duration). Committing that would push
|
|
1317
|
+
// an undo entry and a save for an edit that never happened.
|
|
1318
|
+
if (placed.project === p.project) return
|
|
1319
|
+
p.onProjectChange?.(placed.project)
|
|
1320
|
+
p.onOverlayEdit?.(placed.project)
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if (files && files.length > 0 && p.onImportFilesToTimeline) {
|
|
1325
|
+
e.preventDefault()
|
|
1326
|
+
if (cursorTimeRef.current !== null) { cursorTimeRef.current = null; requestRedraw('overlay') }
|
|
1327
|
+
if (!el) return
|
|
1328
|
+
const rect = el.getBoundingClientRect()
|
|
1329
|
+
// Snapped HERE, unlike the bin path (which snaps inside its own
|
|
1330
|
+
// synchronous `placeDroppedClip` call): a file drop has no placement
|
|
1331
|
+
// call to snap inside of, since the host places the clip later, once
|
|
1332
|
+
// its background import resolves, at exactly the `atTime` reported
|
|
1333
|
+
// now. Resolving the magnet at drop time — same rule, same inputs
|
|
1334
|
+
// (`dropSnapInputs`) the bin path uses — is therefore the only chance
|
|
1335
|
+
// to apply it at all, and it makes the ghost band land on the same
|
|
1336
|
+
// second the real clip eventually will.
|
|
1337
|
+
p.onImportFilesToTimeline(Array.from(files), {
|
|
1338
|
+
atTime: resolveDropPoint({ atTime: dropTimeAt(e.clientX, rect), ...dropSnapInputs() }),
|
|
1339
|
+
preferredTrackIndex: dropTrackIndexAt(e.clientY - rect.top, p.layout),
|
|
1340
|
+
// Captured HERE, at drop time, not read by the host later: the
|
|
1341
|
+
// magnet is editor state the host cannot see, and by the time its
|
|
1342
|
+
// import resolves the operator may have toggled it. The mode during
|
|
1343
|
+
// the gesture is the one the drop meant. (The bin path above needs
|
|
1344
|
+
// no such capture — it places synchronously, so reading
|
|
1345
|
+
// `p.rippleMode` directly is already the drop-time value.)
|
|
1346
|
+
ripple: p.rippleMode ?? false,
|
|
1347
|
+
})
|
|
1348
|
+
return
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Neither — leave it for the browser (no preventDefault).
|
|
1352
|
+
},
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
useEffect(() => {
|
|
1356
|
+
const el = containerRef.current
|
|
1357
|
+
if (!el) return
|
|
1358
|
+
const onOver = (e: DragEvent) => dragHandlersRef.current.over(e)
|
|
1359
|
+
const onLeave = (e: DragEvent) => dragHandlersRef.current.leave(e)
|
|
1360
|
+
const onDrop = (e: DragEvent) => dragHandlersRef.current.drop(e)
|
|
1361
|
+
el.addEventListener('dragover', onOver)
|
|
1362
|
+
el.addEventListener('dragleave', onLeave)
|
|
1363
|
+
el.addEventListener('drop', onDrop)
|
|
1364
|
+
return () => {
|
|
1365
|
+
el.removeEventListener('dragover', onOver)
|
|
1366
|
+
el.removeEventListener('dragleave', onLeave)
|
|
1367
|
+
el.removeEventListener('drop', onDrop)
|
|
1368
|
+
}
|
|
1369
|
+
}, [])
|
|
1370
|
+
|
|
1371
|
+
return (
|
|
1372
|
+
<div
|
|
1373
|
+
ref={containerRef}
|
|
1374
|
+
data-timeline-canvas
|
|
1375
|
+
// Focusable (not tab-stoppable) so a pointer-down can focus it
|
|
1376
|
+
// programmatically — see the `down` handler above — satisfying
|
|
1377
|
+
// Timeline's root-focus guard for Delete/Enter without adding this
|
|
1378
|
+
// surface to the tab order.
|
|
1379
|
+
tabIndex={-1}
|
|
1380
|
+
// `outline-none` because this surface is focused by MOUSE and then
|
|
1381
|
+
// driven by KEYBOARD, which is exactly the sequence that turns
|
|
1382
|
+
// `:focus-visible` on. Pressing space to pause put a focus ring around
|
|
1383
|
+
// the entire timeline — pointer-down focuses the surface silently, then
|
|
1384
|
+
// the first keypress makes the browser decide the focus is now worth
|
|
1385
|
+
// showing, and it draws a box round every track at once.
|
|
1386
|
+
//
|
|
1387
|
+
// Suppressing it costs nothing here: `tabIndex={-1}` keeps this out of
|
|
1388
|
+
// the tab order, so there is no keyboard route to it and no keyboard
|
|
1389
|
+
// user who needs the ring to know where they are. Timeline's own
|
|
1390
|
+
// `tabIndex={0}` root — the one a keyboard user CAN reach — keeps its
|
|
1391
|
+
// own affordance decision separately.
|
|
1392
|
+
className="relative w-full select-none outline-none"
|
|
1393
|
+
style={{ height: surfaceHeight, cursor: 'pointer' }}
|
|
1394
|
+
// Timeline's container click also seeks, via `xToTime` against this
|
|
1395
|
+
// same canvas viewport. The pointer machine has already seeked (on
|
|
1396
|
+
// mousedown) by the time this fires, so swallow it rather than let it
|
|
1397
|
+
// re-seek to the wrong second.
|
|
1398
|
+
onClick={e => e.stopPropagation()}
|
|
1399
|
+
>
|
|
1400
|
+
<canvas ref={contentCanvasRef} className="absolute inset-0 w-full h-full" />
|
|
1401
|
+
<canvas ref={overlayCanvasRef} className="absolute inset-0 w-full h-full pointer-events-none" />
|
|
1402
|
+
</div>
|
|
1403
|
+
)
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// ── Zoom chrome adapter ──────────────────────────────────────────────────
|
|
1407
|
+
// Timeline renders the zoom buttons; the canvas owns what they do. It hands
|
|
1408
|
+
// Timeline this adapter so the chrome stays presentational and the viewport
|
|
1409
|
+
// mutation lives with the surface that draws it. (It was once a seam between
|
|
1410
|
+
// two zoom models — the DOM path's integer multiplier and the canvas'
|
|
1411
|
+
// viewport — but the DOM timeline is gone, so there is only one implementer.)
|
|
1412
|
+
|
|
1413
|
+
export interface ZoomControls {
|
|
1414
|
+
badge: ReactNode
|
|
1415
|
+
zoomIn: () => void
|
|
1416
|
+
zoomOut: () => void
|
|
1417
|
+
fit: () => void
|
|
1418
|
+
showFit: boolean
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
export function useCanvasZoomControls(store: ViewportStore, totalDuration: number): ZoomControls {
|
|
1422
|
+
return useMemo(() => ({
|
|
1423
|
+
badge: <CanvasZoomBadge store={store} totalDuration={totalDuration} />,
|
|
1424
|
+
// Buttons zoom around the middle of the view, which is where the eye is
|
|
1425
|
+
// when there's no cursor to pivot on (the DOM path's `zoomTo` with no
|
|
1426
|
+
// pivot does the same).
|
|
1427
|
+
zoomIn: () => store.set(vp => zoomAtPivot(vp, ZOOM_BUTTON_FACTOR, vp.widthPx / 2, totalDuration)),
|
|
1428
|
+
zoomOut: () => store.set(vp => zoomAtPivot(vp, 1 / ZOOM_BUTTON_FACTOR, vp.widthPx / 2, totalDuration)),
|
|
1429
|
+
fit: () => store.set(vp => fitViewport(vp, totalDuration)),
|
|
1430
|
+
// Always offered: unlike the DOM model, canvas zoom can sit below 1×, so
|
|
1431
|
+
// "am I off fit?" is not simply "is zoom > 1". Keeping it unconditional
|
|
1432
|
+
// also keeps the badge the only part of the chrome that re-renders on zoom.
|
|
1433
|
+
showFit: true,
|
|
1434
|
+
}), [store, totalDuration])
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/** The zoom readout, isolated so wheel-zoom re-renders this span and nothing
|
|
1438
|
+
* else. Reports a fit-relative multiple for continuity with the DOM badge,
|
|
1439
|
+
* where 1× also meant "the whole project fits". */
|
|
1440
|
+
export function CanvasZoomBadge({ store, totalDuration }: { store: ViewportStore; totalDuration: number }) {
|
|
1441
|
+
const viewport = useViewportValue(store)
|
|
1442
|
+
return (
|
|
1443
|
+
<span className="text-[10px] font-mono text-gray-500 w-7 text-center tabular-nums select-none">
|
|
1444
|
+
{formatZoomMultiple(zoomMultiple(viewport, totalDuration))}×
|
|
1445
|
+
</span>
|
|
1446
|
+
)
|
|
1447
|
+
}
|