@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,1828 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas timeline gesture machine (SP5 T5) — every pointer gesture the timeline
|
|
3
|
+
* supports, as a pure reducer.
|
|
4
|
+
*
|
|
5
|
+
* ── Why a machine, and why pure ──────────────────────────────────────────
|
|
6
|
+
* The DOM timeline spread its gestures across four components and two hooks,
|
|
7
|
+
* each attaching its own `document` mousemove/mouseup pair and each holding a
|
|
8
|
+
* little private state (`dragStarted`, `lastUpdated`, `snappedTo`). That works
|
|
9
|
+
* when the browser addresses every event for you. On one canvas surface there
|
|
10
|
+
* is a single pointer stream and no elements, so the state has to be explicit —
|
|
11
|
+
* and once it is explicit it may as well be testable.
|
|
12
|
+
*
|
|
13
|
+
* So: no DOM in this file. Points arrive already in surface-space CSS pixels,
|
|
14
|
+
* everything the machine needs to decide comes in as `PointerContext`, and
|
|
15
|
+
* everything it wants to happen goes out as `PointerEffect[]` for the component
|
|
16
|
+
* to perform. `pointerReducer` is a pure function of (state, event) — every
|
|
17
|
+
* transition below is a unit test, not a browser session.
|
|
18
|
+
*
|
|
19
|
+
* ── Parity ───────────────────────────────────────────────────────────────
|
|
20
|
+
* The DOM rows are the spec, and the behaviours worth naming because they are
|
|
21
|
+
* NOT guessable are:
|
|
22
|
+
*
|
|
23
|
+
* - **Selection happens on release, not on press.** The DOM selected in a
|
|
24
|
+
* `click` handler, which fires after mouseup and was suppressed whenever the
|
|
25
|
+
* press had turned into a drag. Same here: a press that stays under the drag
|
|
26
|
+
* threshold selects, one that crosses it does not.
|
|
27
|
+
* - **A plain click on an unselected clip also seeks** to the clicked time,
|
|
28
|
+
* unsnapped; an additive click, or a click on an already-selected clip, does
|
|
29
|
+
* not (VisualTrackRow.tsx's `if (!additive && !isSel)`). Audio bars never
|
|
30
|
+
* seek on click — AudioTrackRow has no such line. A caption seeks too, but
|
|
31
|
+
* to its own start plus half a frame rather than to the clicked time; the
|
|
32
|
+
* reasoning is at the seek itself, in `pointerUp`.
|
|
33
|
+
* - **Additive means shift OR meta OR ctrl**, and the resulting selection is
|
|
34
|
+
* computed by `toggleSelection`, which the host still owns: the machine emits
|
|
35
|
+
* `{type:'select', id, additive}` so Timeline's existing `handleSelectItem`
|
|
36
|
+
* applies it, including clearing the caption selection. The item↔caption
|
|
37
|
+
* mutual exclusivity therefore cannot drift.
|
|
38
|
+
* - **Per-move `onProjectChange`, commit-time `onOverlayEdit`** — the callback
|
|
39
|
+
* split every DOM row uses. Emitted here as `projectChange` and `commit`.
|
|
40
|
+
* - **Ripple mode collapses gaps on every trim move**, exactly where
|
|
41
|
+
* VisualTrackRow does it (after the multi-selection delta, before the
|
|
42
|
+
* callback), and nowhere else: moves and audio edits never collapse.
|
|
43
|
+
* - **Trims recompute from the project as it was when the press began**;
|
|
44
|
+
* moves accumulate from the last emitted project (the DOM's `lastUpdated`),
|
|
45
|
+
* because the cross-track search needs to see the tracks it has been pruning.
|
|
46
|
+
*
|
|
47
|
+
* ── The four trim-op bindings (plan decision 8) ──────────────────────────
|
|
48
|
+
* plain edge-drag trim (ripple-aware, the DOM behaviour)
|
|
49
|
+
* Alt + edge-drag roll the shared boundary with the adjacent neighbour
|
|
50
|
+
* Alt + body-drag slip the source window, item stays put
|
|
51
|
+
* Cmd/Ctrl + body-drag slide the item, neighbours absorb it
|
|
52
|
+
* Modifiers are read at press time, so releasing Alt mid-drag doesn't change
|
|
53
|
+
* what the gesture is. Cmd/Ctrl is also the additive-selection modifier, which
|
|
54
|
+
* doesn't collide: that path only fires for presses that never became drags.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
|
|
58
|
+
import type { Project } from '../../../types'
|
|
59
|
+
import { reflowMagneticLanes } from '../../audioMagnet'
|
|
60
|
+
import { laneOf, normalizeCaptionLanes, resolveDropLane, sameLaneNeighbours } from '../../captionLanes'
|
|
61
|
+
import { collapseGaps, rollEdit, slideItem, slipItem } from '../../cuts'
|
|
62
|
+
import { canKeyframe, enableKeyframing, moveKeyframe, setKeyframe, transformProps, valueAt } from '../../keyframeOps'
|
|
63
|
+
import { applyMoveDeltaToSelection, applyResizeDeltaToSelection } from '../multiSelectOps'
|
|
64
|
+
import { AUDIO_LANE_HEIGHT_PX, CAPTION_ROW_HEIGHT_PX, computeDerivedTiming, groupAudioLanes, mapTrackItems, moveItemAcrossTracks, normalizeTracks, resolveTargetTrackIdx, trackItems, updateAudioTrack } from '../timeline-model'
|
|
65
|
+
import { DRAG_THRESHOLD_PX, computeResizedItem, resizeWindowedItem, type Draggable } from '../useItemDragDrop'
|
|
66
|
+
import type { TimelineLayout } from './draw'
|
|
67
|
+
import { keyframeUnionTimes } from './keyframe-strip'
|
|
68
|
+
import {
|
|
69
|
+
PLAYHEAD_GRAB_PX,
|
|
70
|
+
hitTest,
|
|
71
|
+
isCaptionHit,
|
|
72
|
+
isEdgeHit,
|
|
73
|
+
isEmptyHit,
|
|
74
|
+
itemsInRect,
|
|
75
|
+
normalizeRect,
|
|
76
|
+
type HitResult,
|
|
77
|
+
type HitTestOptions,
|
|
78
|
+
type Point,
|
|
79
|
+
type SurfaceRect,
|
|
80
|
+
} from './hit-test'
|
|
81
|
+
import {
|
|
82
|
+
DEFAULT_SNAP_CONFIG,
|
|
83
|
+
applySnap,
|
|
84
|
+
createSnapState,
|
|
85
|
+
snapPointsExcluding,
|
|
86
|
+
snapPointsForSpan,
|
|
87
|
+
type SnapConfig,
|
|
88
|
+
type SnapPoint,
|
|
89
|
+
type SnapResult,
|
|
90
|
+
type SnapState,
|
|
91
|
+
type SnapStrength,
|
|
92
|
+
} from './snap'
|
|
93
|
+
import { timeToX, xToTime, type Viewport } from './viewport'
|
|
94
|
+
|
|
95
|
+
// ── Inputs ───────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface Modifiers {
|
|
98
|
+
shift: boolean
|
|
99
|
+
alt: boolean
|
|
100
|
+
meta: boolean
|
|
101
|
+
ctrl: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const NO_MODIFIERS: Modifiers = { shift: false, alt: false, meta: false, ctrl: false }
|
|
105
|
+
|
|
106
|
+
/** The DOM's additive-selection test, verbatim from both row components. */
|
|
107
|
+
export function isAdditive(m: Modifiers): boolean {
|
|
108
|
+
return m.shift || m.meta || m.ctrl
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Everything the machine needs to know about the world, injected per event so
|
|
112
|
+
* the machine never holds a stale project or viewport of its own. */
|
|
113
|
+
export interface PointerContext {
|
|
114
|
+
project: Project
|
|
115
|
+
layout: TimelineLayout
|
|
116
|
+
viewport: Viewport
|
|
117
|
+
selectedIds: readonly string[]
|
|
118
|
+
/** Clip/audio boundaries — `computeDerivedTiming(project).snapBoundaries`. */
|
|
119
|
+
snapBoundaries: readonly number[]
|
|
120
|
+
/** Content duration plus the timeline's drag headroom. */
|
|
121
|
+
totalDuration: number
|
|
122
|
+
rippleMode: boolean
|
|
123
|
+
/** Where the playhead is, so item gestures can snap to it. */
|
|
124
|
+
playheadTime: number
|
|
125
|
+
/** The project's frame rate. Needed for exactly one thing: a click on a
|
|
126
|
+
* caption seeks half a frame INTO the segment rather than to its start —
|
|
127
|
+
* see the seek in `pointerUp`. No gesture's arithmetic uses it. */
|
|
128
|
+
fps: number
|
|
129
|
+
snapConfig?: SnapConfig
|
|
130
|
+
hitTestOptions?: HitTestOptions
|
|
131
|
+
/** The host's current keyframe selection, or null/undefined for none. Read
|
|
132
|
+
* ONLY by `pointerUp`'s `keyframe-move` branch, to decide whether a
|
|
133
|
+
* diamond retime should carry the selection to its new `t` — the machine
|
|
134
|
+
* is otherwise stateless about selection (see `KeyframeSelection`), this
|
|
135
|
+
* is the one gesture where "was the thing I just dragged the selected
|
|
136
|
+
* one?" has to be answered mid-commit rather than left to the host. */
|
|
137
|
+
selectedKeyframe?: KeyframeSelection | null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type PointerMachineEvent =
|
|
141
|
+
| { type: 'pointerDown'; point: Point; modifiers: Modifiers; ctx: PointerContext }
|
|
142
|
+
| { type: 'pointerMove'; point: Point; modifiers: Modifiers; ctx: PointerContext }
|
|
143
|
+
| { type: 'pointerUp'; point: Point; modifiers: Modifiers; ctx: PointerContext }
|
|
144
|
+
| { type: 'doubleClick'; point: Point; modifiers: Modifiers; ctx: PointerContext }
|
|
145
|
+
/** Pointer lost (leave, blur, unmount). Drops the gesture WITHOUT committing;
|
|
146
|
+
* whatever the last `projectChange` emitted stands, exactly as it would if
|
|
147
|
+
* the DOM path's mouseup listener never fired. */
|
|
148
|
+
| { type: 'cancel' }
|
|
149
|
+
|
|
150
|
+
// ── Outputs ──────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export type Cursor = 'default' | 'pointer' | 'grab' | 'grabbing' | 'ew-resize' | 'nwse-resize' | 'nesw-resize' | 'crosshair'
|
|
153
|
+
|
|
154
|
+
export type PointerEffect =
|
|
155
|
+
/** `clock.set` — move the playhead. */
|
|
156
|
+
| { type: 'seek'; time: number }
|
|
157
|
+
/** Timeline's `handleSelectItem(id, additive)`. */
|
|
158
|
+
| { type: 'select'; id: string | null; additive: boolean }
|
|
159
|
+
/** `onProjectChange` — a live, uncommitted edit. Fires once per move. */
|
|
160
|
+
| { type: 'projectChange'; project: Project }
|
|
161
|
+
/** `onOverlayEdit` — the gesture is finished, persist it. */
|
|
162
|
+
| { type: 'commit'; project: Project }
|
|
163
|
+
/** Double-click on a clip or audio bar — `onInspectClip` / `onInspectAudio`. */
|
|
164
|
+
| { type: 'inspect'; target: 'visual' | 'audio'; id: string }
|
|
165
|
+
/** Double-click on a caption block. Deliberately NOT `inspect`: a caption has
|
|
166
|
+
* no inspector dialog — this routes to focusing its row in the transcript
|
|
167
|
+
* sidebar, which is where caption text is edited now that the inline
|
|
168
|
+
* contentEditable row is gone. */
|
|
169
|
+
| { type: 'editCaption'; id: string }
|
|
170
|
+
/** The surface's CSS cursor. Emitted only when it changes. */
|
|
171
|
+
| { type: 'cursor'; cursor: Cursor }
|
|
172
|
+
/** Where to draw the snap guide and how hard it is holding, or nulls to take
|
|
173
|
+
* it down. Emitted only when it CHANGES — a drag held against one boundary
|
|
174
|
+
* emits once on capture and once on release, not sixty times a second. */
|
|
175
|
+
| { type: 'snapGuide'; time: number | null; strength: SnapStrength | null }
|
|
176
|
+
/** The rubber-band box to paint, or null to take it down. */
|
|
177
|
+
| { type: 'marquee'; rect: SurfaceRect | null }
|
|
178
|
+
/** What a finished marquee caught. Separate from `select` because a marquee
|
|
179
|
+
* replaces (or extends) the whole selection in one step — replaying it as N
|
|
180
|
+
* single `select` effects would make the host apply N re-renders and, worse,
|
|
181
|
+
* would need the first to be non-additive and the rest additive. */
|
|
182
|
+
| { type: 'selectMany'; ids: string[]; additive: boolean }
|
|
183
|
+
/** A keyframe diamond became the selected keyframe. Item-relative `t`,
|
|
184
|
+
* matching `Keyframe.t`. The host holds this selection; the machine is
|
|
185
|
+
* stateless about it, exactly as it is about item selection. */
|
|
186
|
+
| { type: 'selectKeyframe'; itemId: string; t: number }
|
|
187
|
+
|
|
188
|
+
/** The host's record of which diamond is selected. A diamond is a time on an
|
|
189
|
+
* item — the props at that time are derived when acted on, never stored here. */
|
|
190
|
+
export interface KeyframeSelection {
|
|
191
|
+
itemId: string
|
|
192
|
+
/** ITEM-RELATIVE seconds, matching `Keyframe.t` and `HitResult.kfT`. Never
|
|
193
|
+
* absolute timeline time. */
|
|
194
|
+
t: number
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── State ────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export type GestureKind =
|
|
200
|
+
| 'move'
|
|
201
|
+
| 'trim'
|
|
202
|
+
| 'roll'
|
|
203
|
+
| 'slip'
|
|
204
|
+
| 'slide'
|
|
205
|
+
| 'audio-move'
|
|
206
|
+
| 'audio-trim'
|
|
207
|
+
/** Dragging an audio bar's fade-in or fade-out grip. See `applyAudioFade`. */
|
|
208
|
+
| 'audio-fade'
|
|
209
|
+
/** Dragging a keyframe-strip diamond to retime it. See `applyKeyframeMove`. */
|
|
210
|
+
| 'keyframe-move'
|
|
211
|
+
/** Re-timing a caption block. Rides the clip group-move path, so a mixed
|
|
212
|
+
* clip+caption selection travels as one body and lands as one undo. */
|
|
213
|
+
| 'caption-move'
|
|
214
|
+
/** Dragging one caption block's in/out edge. Single-segment: multi-caption
|
|
215
|
+
* trim is out of scope for v1. */
|
|
216
|
+
| 'caption-trim'
|
|
217
|
+
/** Dragging the playhead. Lives on the ruler strip only. */
|
|
218
|
+
| 'scrub'
|
|
219
|
+
/** Dragging a rubber-band box across empty track area to select. */
|
|
220
|
+
| 'marquee'
|
|
221
|
+
|
|
222
|
+
export interface Press {
|
|
223
|
+
origin: Point
|
|
224
|
+
/** The timeline time under `origin.x` when the press landed.
|
|
225
|
+
*
|
|
226
|
+
* Every gesture measures its travel from HERE, not from `origin.x`, because
|
|
227
|
+
* a raw pixel delta is blind to the view moving underneath it: edge
|
|
228
|
+
* auto-scroll pans the viewport while the pointer is held still, and
|
|
229
|
+
* `point.x - origin.x` is unchanged by that pan, so the dragged item would
|
|
230
|
+
* sit at a fixed time while the timeline slid out from under the cursor —
|
|
231
|
+
* the clip visibly drifting away from the hand holding it. Anchoring in
|
|
232
|
+
* time makes the same held pointer resolve to a later time on every panned
|
|
233
|
+
* frame, which is exactly what keeps the item under the cursor. */
|
|
234
|
+
originTime: number
|
|
235
|
+
modifiers: Modifiers
|
|
236
|
+
hit: HitResult
|
|
237
|
+
/** The project as it stood when the press began. Trims and the three
|
|
238
|
+
* neighbour-aware ops recompute from here on every move, so dragging back and
|
|
239
|
+
* forth cannot compound. */
|
|
240
|
+
baseProject: Project
|
|
241
|
+
/** Was the pressed item already selected? Drives the DOM's conditional seek
|
|
242
|
+
* on a plain click. */
|
|
243
|
+
wasSelected: boolean
|
|
244
|
+
/** The project's boundaries as they stood when the press began, already
|
|
245
|
+
* tiered by `tieredBoundaries` against the row pressed on.
|
|
246
|
+
*
|
|
247
|
+
* Captured, never read live. The host echoes every `projectChange` back
|
|
248
|
+
* through Timeline's re-render, and that re-render recomputes the boundary
|
|
249
|
+
* set from the echoed project — so mid-gesture the live list contains the
|
|
250
|
+
* dragged item's own CURRENT edges, not just its press-time ones.
|
|
251
|
+
* `itemSnapPoints` only excludes the press-time pair, so a gesture reading
|
|
252
|
+
* live boundaries would find its own last position back in the magnet list
|
|
253
|
+
* and snap to itself. Capturing once, here, is what makes that exclusion
|
|
254
|
+
* complete.
|
|
255
|
+
*
|
|
256
|
+
* This is the DEFAULT tiering — the one every gesture except a single-item
|
|
257
|
+
* visual/audio MOVE uses as-is. Those two re-tier against the row the item
|
|
258
|
+
* is currently over instead of the row it started on (see `itemSnapPoints`'s
|
|
259
|
+
* `current` parameter); they do so by re-running `tieredBoundaries` fresh
|
|
260
|
+
* each move, off `baseProject` below (not off this field), so the id-based
|
|
261
|
+
* exclusion stays exact and the recompute never reads a live, re-rendered
|
|
262
|
+
* project either. */
|
|
263
|
+
snapBoundaries: readonly SnapPoint[]
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export type MachineState =
|
|
267
|
+
| { kind: 'idle'; cursor: Cursor }
|
|
268
|
+
| { kind: 'pressed'; cursor: Cursor; press: Press }
|
|
269
|
+
| {
|
|
270
|
+
kind: 'dragging'
|
|
271
|
+
cursor: Cursor
|
|
272
|
+
press: Press
|
|
273
|
+
gesture: GestureKind
|
|
274
|
+
snap: SnapState
|
|
275
|
+
/** The boundary the guide is currently drawn on, or null. Held here (not
|
|
276
|
+
* derived from `snap`) because for a span gesture `snap.snappedTo` is a
|
|
277
|
+
* candidate START, which is not where the guide belongs — see
|
|
278
|
+
* `spanSnapGuide`. */
|
|
279
|
+
guide: SnapGuide | null
|
|
280
|
+
/** The most recent project this gesture emitted — what a commit persists. */
|
|
281
|
+
lastProject: Project
|
|
282
|
+
/** Latched once the gesture has pulled clear of where it started, and
|
|
283
|
+
* never cleared. Arms the origin boundary so a butted clip can snap back
|
|
284
|
+
* onto its neighbour — see `originGuard`. */
|
|
285
|
+
escaped: boolean
|
|
286
|
+
/** `keyframe-move` only: the most recent landed `toT` this gesture has
|
|
287
|
+
* produced (see `Applied.keyframeLandedT`), carried forward across a
|
|
288
|
+
* no-op frame rather than clobbered by `undefined`. `pointerUp` reads
|
|
289
|
+
* this to follow the selection onto the diamond's new time. Undefined
|
|
290
|
+
* for every gesture but `keyframe-move`. */
|
|
291
|
+
keyframeT?: number
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function initialMachineState(): MachineState {
|
|
295
|
+
return { kind: 'idle', cursor: 'default' }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
const EPSILON = 1e-6
|
|
301
|
+
|
|
302
|
+
function clamp(v: number, lo: number, hi: number): number {
|
|
303
|
+
return Math.max(lo, Math.min(hi, v))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function travelled(a: Point, b: Point): number {
|
|
307
|
+
return Math.hypot(a.x - b.x, a.y - b.y)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Cursor for a resting pointer over a hit — the DOM rows' affordances:
|
|
311
|
+
* `cursor-ew-resize` on edge handles, `cursor-grab` on item bodies.
|
|
312
|
+
*
|
|
313
|
+
* A fade GRIP is a resize too, but of the fade envelope rather than the clip,
|
|
314
|
+
* and it sits at a top CORNER — so it gets a DIAGONAL resize cursor pointing
|
|
315
|
+
* along that corner (`nwse-resize` for a top-left fade-in grip, `nesw-resize`
|
|
316
|
+
* for a top-right fade-out grip). That keeps it distinguishable from an edge
|
|
317
|
+
* trim's horizontal `ew-resize`: both read as "drag to resize", but you can
|
|
318
|
+
* tell by the cursor whether you've grabbed the fade or the clip edge.
|
|
319
|
+
*
|
|
320
|
+
* Empty timeline is the plain arrow, not `pointer`. A hand cursor promises a
|
|
321
|
+
* thing you can click, and the gaps between clips are not a thing — they are
|
|
322
|
+
* where you drag out a selection. `pointer` there read as "everything on this
|
|
323
|
+
* surface is a button", which is the opposite of what a track area is.
|
|
324
|
+
* The ruler keeps `ew-resize`, because scrubbing IS a horizontal drag. */
|
|
325
|
+
export function cursorForHit(hit: HitResult): Cursor {
|
|
326
|
+
switch (hit.kind) {
|
|
327
|
+
case 'audio-fade':
|
|
328
|
+
return hit.side === 'out' ? 'nesw-resize' : 'nwse-resize'
|
|
329
|
+
// A diamond only ever moves horizontally along its own item — the same
|
|
330
|
+
// retime-in-place gesture a trim edge does, so it gets the same cursor.
|
|
331
|
+
case 'keyframe':
|
|
332
|
+
return 'ew-resize'
|
|
333
|
+
case 'item-edge':
|
|
334
|
+
case 'audio-edge':
|
|
335
|
+
case 'caption-edge':
|
|
336
|
+
return 'ew-resize'
|
|
337
|
+
case 'item-body':
|
|
338
|
+
case 'audio-body':
|
|
339
|
+
case 'caption-body':
|
|
340
|
+
return 'grab'
|
|
341
|
+
case 'ruler':
|
|
342
|
+
return 'ew-resize'
|
|
343
|
+
default:
|
|
344
|
+
return 'default'
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function cursorForGesture(gesture: GestureKind): Cursor {
|
|
349
|
+
switch (gesture) {
|
|
350
|
+
case 'audio-fade':
|
|
351
|
+
return 'nwse-resize' // the diagonal fade-grip cursor, held through the drag
|
|
352
|
+
case 'trim':
|
|
353
|
+
case 'roll':
|
|
354
|
+
case 'audio-trim':
|
|
355
|
+
case 'caption-trim':
|
|
356
|
+
case 'scrub':
|
|
357
|
+
case 'keyframe-move':
|
|
358
|
+
return 'ew-resize'
|
|
359
|
+
case 'marquee':
|
|
360
|
+
return 'crosshair'
|
|
361
|
+
default:
|
|
362
|
+
return 'grabbing'
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** What a press on `hit` with `modifiers` becomes once it crosses the drag
|
|
367
|
+
* threshold. Null for hits that never start a drag gesture. */
|
|
368
|
+
export function resolveGesture(hit: HitResult, modifiers: Modifiers): GestureKind | null {
|
|
369
|
+
switch (hit.kind) {
|
|
370
|
+
case 'item-edge':
|
|
371
|
+
return modifiers.alt ? 'roll' : 'trim'
|
|
372
|
+
case 'item-body':
|
|
373
|
+
if (modifiers.alt) return 'slip'
|
|
374
|
+
return modifiers.meta || modifiers.ctrl ? 'slide' : 'move'
|
|
375
|
+
// Audio has no roll/slip/slide — those ops are visual-track vocabulary in
|
|
376
|
+
// cuts.ts — so modifiers fall through to the plain gesture.
|
|
377
|
+
case 'audio-edge':
|
|
378
|
+
return 'audio-trim'
|
|
379
|
+
case 'audio-body':
|
|
380
|
+
return 'audio-move'
|
|
381
|
+
// No modifiers of its own — a fade grip does exactly one thing.
|
|
382
|
+
case 'audio-fade':
|
|
383
|
+
return 'audio-fade'
|
|
384
|
+
// A keyframe diamond does exactly one thing too — retime.
|
|
385
|
+
case 'keyframe':
|
|
386
|
+
return 'keyframe-move'
|
|
387
|
+
// Captions have no roll/slip/slide either — there is no source window to
|
|
388
|
+
// slip and no neighbour to roll against — so modifiers fall through here
|
|
389
|
+
// exactly as they do for audio.
|
|
390
|
+
case 'caption-edge':
|
|
391
|
+
return 'caption-trim'
|
|
392
|
+
case 'caption-body':
|
|
393
|
+
return 'caption-move'
|
|
394
|
+
// Empty track area drags out a selection box. The ruler is handled on
|
|
395
|
+
// press (it scrubs immediately, so it never reaches this resolution), and
|
|
396
|
+
// is deliberately absent here.
|
|
397
|
+
case 'empty-row':
|
|
398
|
+
case 'empty-lane':
|
|
399
|
+
case 'background':
|
|
400
|
+
return 'marquee'
|
|
401
|
+
default:
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Snap targets for a gesture that moves an item: clip and audio boundaries
|
|
407
|
+
* and the playhead — minus whatever the gesture is itself dragging, which
|
|
408
|
+
* would otherwise pin it to where it started.
|
|
409
|
+
*
|
|
410
|
+
* Boundaries come from `press.snapBoundaries`, captured once at press time,
|
|
411
|
+
* NOT `ctx.snapBoundaries`. The host re-derives `ctx.snapBoundaries` from
|
|
412
|
+
* every echoed `projectChange`, so a live read would put the item's own
|
|
413
|
+
* moving edges back into the magnet list mid-gesture (see the `Press.
|
|
414
|
+
* snapBoundaries` doc). The playhead is read from `ctx` because it cannot
|
|
415
|
+
* change during an item gesture.
|
|
416
|
+
*
|
|
417
|
+
* `current`, when passed, overrides which row is tiered STRONG: instead of
|
|
418
|
+
* `press.snapBoundaries` (frozen at the press-time row), this re-runs
|
|
419
|
+
* `tieredBoundaries` fresh against `press.baseProject` — still a frozen
|
|
420
|
+
* project, so the id-based exclusion (`press.hit.itemId`) stays exact and
|
|
421
|
+
* this is no less immune to the self-snap problem above — but tiered
|
|
422
|
+
* against the row the caller says the item is CURRENTLY over rather than
|
|
423
|
+
* the row it started on. Only a single-item visual/audio MOVE passes this;
|
|
424
|
+
* every other gesture stays on the press-time row (see call sites). */
|
|
425
|
+
function itemSnapPoints(
|
|
426
|
+
ctx: PointerContext,
|
|
427
|
+
press: Press,
|
|
428
|
+
exclude: readonly number[],
|
|
429
|
+
current?: { trackIdx?: number; laneIdx?: number },
|
|
430
|
+
): SnapPoint[] {
|
|
431
|
+
const boundaries = current
|
|
432
|
+
? tieredBoundaries(press.baseProject, current.trackIdx, current.laneIdx, press.hit.itemId)
|
|
433
|
+
: press.snapBoundaries
|
|
434
|
+
// The playhead is STRONG regardless of tier: it belongs to no track, and
|
|
435
|
+
// parking a cut on it is always a deliberate act rather than an accident of
|
|
436
|
+
// what happens to be on the row above.
|
|
437
|
+
const points: SnapPoint[] = [...boundaries, { time: ctx.playheadTime, strength: 'strong' }]
|
|
438
|
+
return snapPointsExcluding(points, exclude)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Every boundary in the project, tiered against the row the gesture is working
|
|
443
|
+
* on. Captured once per press for most gestures (see `Press.snapBoundaries`);
|
|
444
|
+
* a single-item visual/audio MOVE instead calls this fresh on every move,
|
|
445
|
+
* with the CURRENT row (see `itemSnapPoints`'s `current` parameter).
|
|
446
|
+
*
|
|
447
|
+
* "Own track" used to mean the row the press LANDED on, held fixed for the
|
|
448
|
+
* whole gesture even once a cross-track move carried the item somewhere else
|
|
449
|
+
* — deliberately, to avoid the magnets under the cursor re-ranking (and the
|
|
450
|
+
* clip appearing to lurch) the instant it crossed a row boundary. That
|
|
451
|
+
* decision is REVERSED for a single-item move: dragging a clip onto another
|
|
452
|
+
* track and getting only the origin track's strong magnets reads as "snapping
|
|
453
|
+
* doesn't work over here", which is worse than the lurch it was avoiding — so
|
|
454
|
+
* for `move`/`audio-move` the strong tier now follows the row the item is
|
|
455
|
+
* CURRENTLY over, and the origin row's magnets fall back to weak the moment
|
|
456
|
+
* the drag has crossed into a new one. Every other gesture (trim, roll, slip,
|
|
457
|
+
* slide, the two audio/caption trims, scrub) never changes row mid-gesture,
|
|
458
|
+
* so this reversal is invisible to them — they still tier once, at press time,
|
|
459
|
+
* against the row they're on for their entire run.
|
|
460
|
+
*
|
|
461
|
+
* "The row it is CURRENTLY over" means the row `moveItemAcrossTracks` actually
|
|
462
|
+
* PLACES it on, which is not the row the vertical travel points at — see
|
|
463
|
+
* `applyMove`'s call site and `resolveTargetTrackIdx`. A multi-select move is
|
|
464
|
+
* exempt for a different reason: `applyMoveDeltaToSelection` shifts a group in
|
|
465
|
+
* place and never changes anyone's track, so a group's current row IS its
|
|
466
|
+
* press-time row and `applyGroupMove` correctly stays on the frozen tier.
|
|
467
|
+
*
|
|
468
|
+
* Audio lanes tier against the LANE, matching how `hitTest` addresses them: a
|
|
469
|
+
* lane can hold several tracks, and to an editor they are one row.
|
|
470
|
+
*
|
|
471
|
+
* `skipId` drops the dragged item's OWN two boundaries, by identity rather than
|
|
472
|
+
* by value. That distinction is the whole fix for butted clips: narration is
|
|
473
|
+
* recorded as takes that butt end-to-end, so a voice clip's start is numerically
|
|
474
|
+
* equal to its neighbour's end, and the old value-based exclusion in
|
|
475
|
+
* `itemSnapPoints` deleted BOTH — handing every butted clip a timeline where the
|
|
476
|
+
* one boundary it most wants to snap to had been quietly removed. Excluding by
|
|
477
|
+
* identity keeps the neighbour's boundary in the set; `originGuard` then handles
|
|
478
|
+
* the separate problem of not being glued to it at rest.
|
|
479
|
+
*/
|
|
480
|
+
function tieredBoundaries(
|
|
481
|
+
project: Project,
|
|
482
|
+
trackIdx?: number,
|
|
483
|
+
laneIdx?: number,
|
|
484
|
+
skipId?: string,
|
|
485
|
+
): SnapPoint[] {
|
|
486
|
+
const points: SnapPoint[] = []
|
|
487
|
+
trackItems(project).forEach((items, idx) => {
|
|
488
|
+
const strength: SnapStrength = idx === trackIdx ? 'strong' : 'weak'
|
|
489
|
+
for (const item of items) {
|
|
490
|
+
if (item.id === skipId) continue
|
|
491
|
+
points.push({ time: item.start, strength }, { time: item.end, strength })
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
// Grouped, not read off `track.lane` directly: a track with no `lane` gets
|
|
495
|
+
// an auto-assigned one, so a raw `track.lane ?? 0` would file every
|
|
496
|
+
// unlabelled track under lane 0 and call them all same-lane. `hitTest`
|
|
497
|
+
// addresses lanes through this same grouping, and the two have to agree or
|
|
498
|
+
// the tier is assigned against a row the gesture isn't on.
|
|
499
|
+
for (const lane of groupAudioLanes(project.audio?.tracks ?? [], computeDerivedTiming(project).contentDuration)) {
|
|
500
|
+
const strength: SnapStrength = lane.laneIndex === laneIdx ? 'strong' : 'weak'
|
|
501
|
+
for (const track of lane.tracks) {
|
|
502
|
+
if (track.id === skipId) continue
|
|
503
|
+
points.push({ time: track.start, strength }, { time: track.end, strength })
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return points
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* How far this gesture has travelled since the press, in TIMELINE seconds.
|
|
511
|
+
*
|
|
512
|
+
* Measured as "the time under the cursor now, minus the time under the cursor
|
|
513
|
+
* at press" rather than as a pixel delta divided by the scale. The two agree
|
|
514
|
+
* exactly while the viewport holds still, and disagree by the scroll travelled
|
|
515
|
+
* the moment it does not — which is the whole point. Edge auto-scroll pans the
|
|
516
|
+
* view while the pointer is parked in the edge band, re-feeding the machine
|
|
517
|
+
* the SAME screen point every frame; a pixel delta would report no movement at
|
|
518
|
+
* all for those frames, pinning the dragged item to one time while the
|
|
519
|
+
* timeline slid past it. Reading through the live viewport instead means each
|
|
520
|
+
* panned frame resolves that same point to a later time, and whatever the
|
|
521
|
+
* gesture is moving stays under the cursor.
|
|
522
|
+
*
|
|
523
|
+
* Every horizontal gesture shares it — move, trim, roll, slip, slide, audio
|
|
524
|
+
* move/trim/fade, keyframe, caption — so no gesture can drift while its
|
|
525
|
+
* neighbours track. `hasEscaped` deliberately does NOT: a snap-release radius
|
|
526
|
+
* is a distance the user's HAND has to travel, which is a screen measure.
|
|
527
|
+
*/
|
|
528
|
+
function dragDeltaSeconds(ctx: PointerContext, press: Press, point: Point): number {
|
|
529
|
+
return xToTime(point.x, ctx.viewport) - press.originTime
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Has this gesture pulled clear of where it started?
|
|
534
|
+
*
|
|
535
|
+
* Measured in pixels from the press point, which is the same distance every
|
|
536
|
+
* gesture's candidate has travelled from its own origin (a move's start, a
|
|
537
|
+
* trim's edge, a roll's cut all track `point.x` one-for-one), so one test
|
|
538
|
+
* serves them all without knowing which gesture is running.
|
|
539
|
+
*
|
|
540
|
+
* LATCHED by the caller: once true it stays true for the rest of the gesture.
|
|
541
|
+
* That is the whole point. A guard that merely asked "am I far from the origin
|
|
542
|
+
* right now" could never re-arm the origin boundary, because being near it is
|
|
543
|
+
* exactly the moment you want it back.
|
|
544
|
+
*/
|
|
545
|
+
function hasEscaped(ctx: PointerContext, press: Press, point: Point): boolean {
|
|
546
|
+
const releasePx = (ctx.snapConfig ?? DEFAULT_SNAP_CONFIG).releasePx
|
|
547
|
+
return Math.abs(point.x - press.origin.x) >= releasePx
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Is `point` within grab range of the playhead line? Measured against
|
|
551
|
+
* `ctx.playheadTime` — the time the overlay draws the line at — through the
|
|
552
|
+
* live viewport, so the band tracks the line at any zoom or scroll. Y is not
|
|
553
|
+
* tested: the line spans the whole surface, so the grab is available on every
|
|
554
|
+
* row (which rows it actually WINS on is `grabsPlayhead`'s call). */
|
|
555
|
+
function isOverPlayhead(point: Point, ctx: PointerContext): boolean {
|
|
556
|
+
if (!(ctx.viewport.pxPerSecond > 0)) return false
|
|
557
|
+
return Math.abs(point.x - timeToX(ctx.playheadTime, ctx.viewport)) <= PLAYHEAD_GRAB_PX
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A near-playhead press that should grab the playhead and scrub, exactly as
|
|
561
|
+
* the ruler does. Trim EDGES win over the grab — they are the precise target,
|
|
562
|
+
* so grabbing the playhead where it crosses a clip yields the body but never
|
|
563
|
+
* the handle (decision D1); everything else, clip and audio bodies and empty
|
|
564
|
+
* space alike, yields to the grab (D2). Fade GRIPS win over it too, by the
|
|
565
|
+
* same D1 reasoning — an even smaller, more deliberate target than a trim
|
|
566
|
+
* edge — which is why this checks `hit.kind` directly rather than folding
|
|
567
|
+
* into `isEdgeHit` (whose contract is specifically "describes a trim grab",
|
|
568
|
+
* read via `hit.edge`; a fade grip has no `edge`, only `side`). Keyframe
|
|
569
|
+
* diamonds win for the identical reason — same size class as a fade grip,
|
|
570
|
+
* same "smaller and more deliberate than a trim edge" case for the grab. */
|
|
571
|
+
function grabsPlayhead(hit: HitResult, point: Point, ctx: PointerContext): boolean {
|
|
572
|
+
return !isEdgeHit(hit) && hit.kind !== 'audio-fade' && hit.kind !== 'keyframe' && isOverPlayhead(point, ctx)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The origin values to keep suppressed.
|
|
577
|
+
*
|
|
578
|
+
* A gesture starts ON its own origin, so any magnet sitting there captures it
|
|
579
|
+
* instantly and then demands a full release radius of travel before the item
|
|
580
|
+
* moves at all — the item stays put, then jumps 44px in one step. That is why
|
|
581
|
+
* origins were excluded in the first place.
|
|
582
|
+
*
|
|
583
|
+
* But excluding them for the WHOLE gesture is what made a butted clip
|
|
584
|
+
* unsnappable back: the boundary you want to return to is the boundary you
|
|
585
|
+
* started on, and a butted neighbour contributes the very same value. Dropping
|
|
586
|
+
* the suppression the moment the gesture has pulled clear gives both halves —
|
|
587
|
+
* the clip breaks away cleanly, and the boundary re-arms behind it so butting
|
|
588
|
+
* the two back together catches.
|
|
589
|
+
*/
|
|
590
|
+
function originGuard(escaped: boolean, origins: readonly number[]): readonly number[] {
|
|
591
|
+
return escaped ? [] : origins
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Snap targets for dragging the playhead itself: the clip/audio boundaries.
|
|
595
|
+
* All STRONG — the playhead belongs to no track, so there is no "own row" to
|
|
596
|
+
* rank against, and parking it on a cut is the point of the gesture. */
|
|
597
|
+
function playheadSnapPoints(ctx: PointerContext): SnapPoint[] {
|
|
598
|
+
return snapPointsExcluding(ctx.snapBoundaries.map(time => ({ time, strength: 'strong' as const })), [])
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function replaceVisualItem(project: Project, id: string, patch: Partial<VisualItem>): Project {
|
|
602
|
+
return {
|
|
603
|
+
...project,
|
|
604
|
+
tracks: mapTrackItems(project, items =>
|
|
605
|
+
items.map(item => (item.id === id ? { ...item, ...patch } : item)),
|
|
606
|
+
),
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** `replaceVisualItem`'s caption twin. `patch` is deliberately typed as a
|
|
611
|
+
* Partial of the whole segment rather than a start/end pair, but only ever
|
|
612
|
+
* called with one edge — see `applyCaptionTrim` on why nothing else moves. */
|
|
613
|
+
function replaceCaptionSegment(project: Project, id: string, patch: Partial<CaptionSegment>): Project {
|
|
614
|
+
if (!project.captions) return project
|
|
615
|
+
return {
|
|
616
|
+
...project,
|
|
617
|
+
captions: {
|
|
618
|
+
...project.captions,
|
|
619
|
+
segments: project.captions.segments.map(seg => (seg.id === id ? { ...seg, ...patch } : seg)),
|
|
620
|
+
},
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** The items either side of `item` on its own track, in timeline order, and
|
|
625
|
+
* only when they actually touch it — a gap means there is no shared boundary
|
|
626
|
+
* to roll. Same adjacency rule `slideItem` uses in cuts.ts. */
|
|
627
|
+
function adjacentOnTrack(project: Project, item: VisualItem): { prev?: VisualItem; next?: VisualItem } {
|
|
628
|
+
const track = trackItems(project).find(t => t.some(other => other.id === item.id))
|
|
629
|
+
if (!track) return {}
|
|
630
|
+
const sorted = [...track].sort((a, b) => a.start - b.start)
|
|
631
|
+
const pos = sorted.findIndex(other => other.id === item.id)
|
|
632
|
+
const before = pos > 0 ? sorted[pos - 1] : undefined
|
|
633
|
+
const after = pos >= 0 && pos < sorted.length - 1 ? sorted[pos + 1] : undefined
|
|
634
|
+
return {
|
|
635
|
+
prev: before && Math.abs(before.end - item.start) <= EPSILON ? before : undefined,
|
|
636
|
+
next: after && Math.abs(after.start - item.end) <= EPSILON ? after : undefined,
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Where a guide line goes, and how hard the magnet holding it pulls. */
|
|
641
|
+
export interface SnapGuide {
|
|
642
|
+
time: number
|
|
643
|
+
strength: SnapStrength
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ── Gesture application ──────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
interface Applied {
|
|
649
|
+
effects: PointerEffect[]
|
|
650
|
+
snap: SnapState
|
|
651
|
+
lastProject: Project
|
|
652
|
+
/** Where the guide belongs after this move, or null for none. Never an
|
|
653
|
+
* effect on its own — the reducer diffs it against the last one and emits
|
|
654
|
+
* only on a change. */
|
|
655
|
+
guide: SnapGuide | null
|
|
656
|
+
/** `keyframe-move` only: the diamond's item-relative landed time (`toT`)
|
|
657
|
+
* after this move. Undefined for every other gesture, and undefined for a
|
|
658
|
+
* `keyframe-move` frame that clamped to a no-op — `pointerUp` falls back
|
|
659
|
+
* to the state's last known value (see the dragging state's `keyframeT`)
|
|
660
|
+
* so a settled no-op frame at release never loses it. */
|
|
661
|
+
keyframeLandedT?: number
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function noChange(snap: SnapState, lastProject: Project): Applied {
|
|
665
|
+
return { effects: [], snap, lastProject, guide: null }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Emit a live edit, unless the op clamped to a no-op and handed back the same
|
|
669
|
+
* project reference (which `rollEdit`/`slipItem`/`slideItem` all do). */
|
|
670
|
+
function change(next: Project, previous: Project, snap: SnapState, guide: SnapGuide | null): Applied {
|
|
671
|
+
if (next === previous) return { effects: [], snap, lastProject: previous, guide }
|
|
672
|
+
return { effects: [{ type: 'projectChange', project: next }], snap, lastProject: next, guide }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Which boundary a SPAN gesture actually landed on.
|
|
677
|
+
*
|
|
678
|
+
* `snapPointsForSpan` makes both edges of a dragged item magnetic by offering
|
|
679
|
+
* two candidate start positions per boundary — `p` (leading edge lands on it)
|
|
680
|
+
* and `p - duration` (trailing edge does). That is the right shape for the
|
|
681
|
+
* magnet and the wrong one for the guide: `snap.snappedTo` is a start, and
|
|
682
|
+
* drawing a line there when the item's TAIL is what caught puts the mark a
|
|
683
|
+
* whole clip away from the edge the user is looking at.
|
|
684
|
+
*
|
|
685
|
+
* The recovery is a membership test against the un-expanded boundary list: a
|
|
686
|
+
* snapped start that is itself a boundary means the head caught, anything else
|
|
687
|
+
* means the tail did and the boundary is one duration later.
|
|
688
|
+
*/
|
|
689
|
+
function spanSnapGuide(snapped: SnapResult, duration: number, points: readonly SnapPoint[]): SnapGuide | null {
|
|
690
|
+
const { snappedTo, strength } = snapped
|
|
691
|
+
if (snappedTo === null || strength === null) return null
|
|
692
|
+
const head = points.some(p => Math.abs(p.time - snappedTo) <= EPSILON)
|
|
693
|
+
return { time: head ? snappedTo : snappedTo + duration, strength }
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** The guide for an EDGE gesture, suppressed when the op refused to put the
|
|
697
|
+
* edge where the magnet asked. Trims clamp to a 0.1s minimum duration and to
|
|
698
|
+
* the source window; a guide left drawn through a clamp would claim an
|
|
699
|
+
* alignment that isn't on screen. */
|
|
700
|
+
function edgeSnapGuide(snapped: SnapResult, landed: number): SnapGuide | null {
|
|
701
|
+
const { snappedTo, strength, time } = snapped
|
|
702
|
+
if (snappedTo === null || strength === null) return null
|
|
703
|
+
return Math.abs(landed - time) <= EPSILON ? { time: snappedTo, strength } : null
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** `snapped.snappedTo` as a guide, for the gestures whose snap value IS the
|
|
707
|
+
* boundary (roll, slide, scrub). */
|
|
708
|
+
function directSnapGuide(snapped: SnapResult): SnapGuide | null {
|
|
709
|
+
if (snapped.snappedTo === null || snapped.strength === null) return null
|
|
710
|
+
return { time: snapped.snappedTo, strength: snapped.strength }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The whole selection travelling as one. The DRAGGED item's leading/trailing
|
|
714
|
+
* edge snaps to the boundaries exactly as a single move does; the delta that
|
|
715
|
+
* lands is what every selected item and audio track then shifts by, clamped as
|
|
716
|
+
* a body so none leaves the timeline. Rebuilt from `press.baseProject` each
|
|
717
|
+
* move so dragging back and forth cannot compound. Shared by `applyMove`,
|
|
718
|
+
* `applyAudioMove` and `applyCaptionMove` — a multi-selection can mix clips,
|
|
719
|
+
* audio bars and captions, so the group moves the same way whichever kind the
|
|
720
|
+
* drag started on.
|
|
721
|
+
*
|
|
722
|
+
* `selection` is a parameter rather than a read of `ctx.selectedIds` because
|
|
723
|
+
* it is not always that: a freshly grabbed caption is not in the host's
|
|
724
|
+
* selection yet on the first move of its own drag. See `applyCaptionMove`. */
|
|
725
|
+
function applyGroupMove(
|
|
726
|
+
ctx: PointerContext,
|
|
727
|
+
press: Press,
|
|
728
|
+
point: Point,
|
|
729
|
+
snap: SnapState,
|
|
730
|
+
lastProject: Project,
|
|
731
|
+
escaped: boolean,
|
|
732
|
+
dragged: { id: string; start: number; end: number },
|
|
733
|
+
selection: readonly string[],
|
|
734
|
+
): Applied {
|
|
735
|
+
const duration = dragged.end - dragged.start
|
|
736
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
737
|
+
const boundaries = itemSnapPoints(ctx, press, originGuard(escaped, [dragged.start, dragged.end]))
|
|
738
|
+
const points = snapPointsForSpan(boundaries, duration)
|
|
739
|
+
const snapped = applySnap(dragged.start + dt, points, ctx.viewport, snap, ctx.snapConfig)
|
|
740
|
+
const next = applyMoveDeltaToSelection(press.baseProject, selection, snapped.time - dragged.start, ctx.totalDuration)
|
|
741
|
+
return change(next, lastProject, snapped.state, spanSnapGuide(snapped, duration, boundaries))
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function applyMove(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
|
|
745
|
+
const item = press.hit.item
|
|
746
|
+
if (!item || press.hit.trackIdx === undefined) return noChange(snap, lastProject)
|
|
747
|
+
|
|
748
|
+
// Multi-select move: the pressed item belongs to a selection of more than
|
|
749
|
+
// one, so the whole selection travels together by the same delta and keeps
|
|
750
|
+
// its shape. Cross-track movement is dropped for the group (see
|
|
751
|
+
// `applyMoveDeltaToSelection`); a single-item move keeps it, below.
|
|
752
|
+
if (ctx.selectedIds.length > 1 && ctx.selectedIds.includes(item.id)) {
|
|
753
|
+
return applyGroupMove(ctx, press, point, snap, lastProject, escaped, item, ctx.selectedIds)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const duration = item.end - item.start
|
|
757
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
758
|
+
const rawStart = clamp(item.start + dt, 0, Math.max(0, ctx.totalDuration - duration))
|
|
759
|
+
|
|
760
|
+
// Which track the item is CURRENTLY ON. Not the POINTED-AT index
|
|
761
|
+
// (`sourceTrackIdx - dy/24`) — `moveItemAcrossTracks` treats that as a mere
|
|
762
|
+
// starting point and searches outward from it, so a pointed-at track the
|
|
763
|
+
// dragged item's KIND can never occupy is not where it goes. Tiering against
|
|
764
|
+
// the pointed-at index therefore ranked the boundaries of a row the item had
|
|
765
|
+
// never been placed on, which is exactly what made a cross-track drag read
|
|
766
|
+
// as "snapping doesn't work over here": drag an overlay down into a
|
|
767
|
+
// same-length gap one row below and the pointed-at index lands on the VIDEO
|
|
768
|
+
// track under it (the 24px drag divisor is roughly half a rendered overlay
|
|
769
|
+
// row, so one row of real travel counts as two steps), the kind gate bounces
|
|
770
|
+
// the drop back up onto the row the cursor is actually over, and the gap's
|
|
771
|
+
// own two edges stay tiered WEAK — a 6px magnet where a 20px one belongs.
|
|
772
|
+
//
|
|
773
|
+
// Running the drop's own search removes the second copy of that arithmetic.
|
|
774
|
+
// (The two can still differ after a mid-drag prune, which renumbers
|
|
775
|
+
// `lastProject`'s tracks under the move's `baseProject`-space `sourceTrackIdx`
|
|
776
|
+
// — pre-existing, and orthogonal to the tier.) `kindOnly` deliberately
|
|
777
|
+
// keeps the COLLISION half of the search out of it — see the option's doc in
|
|
778
|
+
// `timeline-model.ts`; consulting it here would kill the magnet precisely
|
|
779
|
+
// where it is aiming and make the tier lurch between rows mid-slide. (It
|
|
780
|
+
// also makes `ctx.rippleMode` irrelevant to the tier, since ripple mode only
|
|
781
|
+
// ever relaxes that same collision gate.)
|
|
782
|
+
//
|
|
783
|
+
// Resolved against `press.baseProject`, not `lastProject`: `tieredBoundaries`
|
|
784
|
+
// walks `baseProject`, and `press.hit.trackIdx` indexes it too, so all three
|
|
785
|
+
// stay in ONE coordinate space even after a mid-drag prune has renumbered the
|
|
786
|
+
// live project's tracks. The dragged item is filtered out of the kind gate by
|
|
787
|
+
// id, so its own stale position in `baseProject` cannot affect the answer.
|
|
788
|
+
//
|
|
789
|
+
// The window is unread while `kindOnly` is on — the collision gate
|
|
790
|
+
// short-circuits past it — so the RAW (pre-snap) one is passed for the sake
|
|
791
|
+
// of the invariant rather than the arithmetic: if `kindOnly` is ever
|
|
792
|
+
// relaxed, the tier stays a pure function of where the pointer is instead
|
|
793
|
+
// of letting the magnet it produced decide which magnets exist.
|
|
794
|
+
//
|
|
795
|
+
// A past-the-end `currentTrackIdx` is `resolveTargetTrackIdx`'s signal that
|
|
796
|
+
// the drop would MINT a new track. That intentionally matches no row below,
|
|
797
|
+
// so every boundary tiers weak — correct by construction for a track with
|
|
798
|
+
// no neighbours yet, not a case this call site branches on explicitly.
|
|
799
|
+
const dy = point.y - press.origin.y
|
|
800
|
+
const currentTrackIdx = resolveTargetTrackIdx({
|
|
801
|
+
tracks: normalizeTracks(press.baseProject).tracks ?? [],
|
|
802
|
+
item,
|
|
803
|
+
start: rawStart,
|
|
804
|
+
end: rawStart + duration,
|
|
805
|
+
sourceTrackIdx: press.hit.trackIdx,
|
|
806
|
+
dy,
|
|
807
|
+
}, { kindOnly: true })
|
|
808
|
+
|
|
809
|
+
const boundaries = itemSnapPoints(ctx, press, originGuard(escaped, [item.start, item.end]), { trackIdx: currentTrackIdx })
|
|
810
|
+
const points = snapPointsForSpan(boundaries, duration)
|
|
811
|
+
const snapped = applySnap(rawStart, points, ctx.viewport, snap, ctx.snapConfig)
|
|
812
|
+
const start = clamp(snapped.time, 0, Math.max(0, ctx.totalDuration - duration))
|
|
813
|
+
// Clamped against t=0 or the end of the timeline the item is NOT where the
|
|
814
|
+
// magnet put it, so there is nothing to mark.
|
|
815
|
+
const guide = Math.abs(start - snapped.time) <= EPSILON
|
|
816
|
+
? spanSnapGuide(snapped, duration, boundaries)
|
|
817
|
+
: null
|
|
818
|
+
|
|
819
|
+
const next: Project = {
|
|
820
|
+
...lastProject,
|
|
821
|
+
tracks: moveItemAcrossTracks({
|
|
822
|
+
// Server-side shape normalization is best-effort (a project must always
|
|
823
|
+
// open even if migration throws), and the SSE stream's initial frame
|
|
824
|
+
// reads project.json straight off disk with no migration at all — so a
|
|
825
|
+
// legacy-shape project genuinely can reach here. Normalize defensively
|
|
826
|
+
// rather than assume `.tracks` is already `VisualTrack[]`.
|
|
827
|
+
tracks: normalizeTracks(lastProject).tracks ?? [],
|
|
828
|
+
item,
|
|
829
|
+
start,
|
|
830
|
+
end: start + duration,
|
|
831
|
+
sourceTrackIdx: press.hit.trackIdx,
|
|
832
|
+
dy,
|
|
833
|
+
makeSpace: ctx.rippleMode,
|
|
834
|
+
}),
|
|
835
|
+
}
|
|
836
|
+
return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide }
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function applyTrim(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
|
|
840
|
+
const item = press.hit.item
|
|
841
|
+
if (!item || !press.hit.edge) return noChange(snap, lastProject)
|
|
842
|
+
|
|
843
|
+
const edge = press.hit.edge === 'in' ? 'start' : 'end'
|
|
844
|
+
const initTime = edge === 'start' ? item.start : item.end
|
|
845
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
846
|
+
const raw = clamp(initTime + dt, 0, ctx.totalDuration)
|
|
847
|
+
|
|
848
|
+
// The opposite edge stays excluded for the whole gesture — trimming an out
|
|
849
|
+
// edge back onto its own in edge is a zero-length clip, not an alignment.
|
|
850
|
+
// Only the edge being dragged gets the re-arming treatment.
|
|
851
|
+
const fixedEdge = edge === 'start' ? item.end : item.start
|
|
852
|
+
const snapped = applySnap(
|
|
853
|
+
raw,
|
|
854
|
+
itemSnapPoints(ctx, press, [fixedEdge, ...originGuard(escaped, [initTime])]),
|
|
855
|
+
ctx.viewport, snap, ctx.snapConfig,
|
|
856
|
+
)
|
|
857
|
+
const resized = computeResizedItem(item as Draggable, edge, snapped.time)
|
|
858
|
+
|
|
859
|
+
// Trims always rebuild from the pressed-at project, never from the running
|
|
860
|
+
// preview — otherwise ripple's gap collapse would compound move by move.
|
|
861
|
+
let next = replaceVisualItem(press.baseProject, item.id, {
|
|
862
|
+
start: resized.start,
|
|
863
|
+
end: resized.end,
|
|
864
|
+
inPoint: resized.inPoint,
|
|
865
|
+
outPoint: resized.outPoint,
|
|
866
|
+
})
|
|
867
|
+
if (ctx.selectedIds.length > 1 && ctx.selectedIds.includes(item.id)) {
|
|
868
|
+
next = applyResizeDeltaToSelection(next, item.id, ctx.selectedIds, edge, {
|
|
869
|
+
dStart: edge === 'start' ? resized.start - item.start : 0,
|
|
870
|
+
dEnd: edge === 'end' ? resized.end - item.end : 0,
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
if (ctx.rippleMode) next = collapseGaps(next)
|
|
874
|
+
|
|
875
|
+
const guide = edgeSnapGuide(snapped, edge === 'start' ? resized.start : resized.end)
|
|
876
|
+
return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide }
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function applyRoll(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
|
|
880
|
+
const item = press.hit.item
|
|
881
|
+
if (!item || !press.hit.edge) return noChange(snap, lastProject)
|
|
882
|
+
|
|
883
|
+
const { prev, next: after } = adjacentOnTrack(press.baseProject, item)
|
|
884
|
+
const left = press.hit.edge === 'in' ? prev : item
|
|
885
|
+
const right = press.hit.edge === 'in' ? item : after
|
|
886
|
+
// No neighbour means no shared boundary. The gesture stays alive (releasing
|
|
887
|
+
// Alt mid-drag must not silently turn it into a trim) but does nothing.
|
|
888
|
+
if (!left || !right) return noChange(snap, lastProject)
|
|
889
|
+
|
|
890
|
+
const boundary = left.end
|
|
891
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
892
|
+
const candidate = clamp(boundary + dt, 0, ctx.totalDuration)
|
|
893
|
+
// A roll's origin IS the shared boundary, and both neighbours contribute it.
|
|
894
|
+
// The outer edges stay excluded throughout: rolling the cut onto either
|
|
895
|
+
// neighbour's far edge would consume that clip entirely.
|
|
896
|
+
const snapped = applySnap(
|
|
897
|
+
candidate,
|
|
898
|
+
itemSnapPoints(ctx, press, [
|
|
899
|
+
left.start, right.end,
|
|
900
|
+
...originGuard(escaped, [left.end, right.start]),
|
|
901
|
+
]),
|
|
902
|
+
ctx.viewport,
|
|
903
|
+
snap,
|
|
904
|
+
ctx.snapConfig,
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
const rolled = rollEdit(press.baseProject, left.id, right.id, snapped.time - boundary)
|
|
908
|
+
return change(rolled, lastProject, snapped.state, directSnapGuide(snapped))
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function applySlip(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project): Applied {
|
|
912
|
+
const item = press.hit.item
|
|
913
|
+
if (!item) return noChange(snap, lastProject)
|
|
914
|
+
|
|
915
|
+
// Dragging right pulls the media rightwards under a fixed window, so EARLIER
|
|
916
|
+
// frames come into view — the film-under-a-gate model every NLE uses. Hence
|
|
917
|
+
// the sign flip. No snapping: the delta is source time, and every snap point
|
|
918
|
+
// the timeline knows about is timeline time.
|
|
919
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
920
|
+
return change(slipItem(press.baseProject, item.id, -dt), lastProject, snap, null)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function applySlide(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
|
|
924
|
+
const item = press.hit.item
|
|
925
|
+
if (!item) return noChange(snap, lastProject)
|
|
926
|
+
|
|
927
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
928
|
+
const candidate = clamp(item.start + dt, 0, ctx.totalDuration)
|
|
929
|
+
const snapped = applySnap(
|
|
930
|
+
candidate,
|
|
931
|
+
itemSnapPoints(ctx, press, originGuard(escaped, [item.start, item.end])),
|
|
932
|
+
ctx.viewport,
|
|
933
|
+
snap,
|
|
934
|
+
ctx.snapConfig,
|
|
935
|
+
)
|
|
936
|
+
const slid = slideItem(press.baseProject, item.id, snapped.time - item.start)
|
|
937
|
+
return change(slid, lastProject, snapped.state, directSnapGuide(snapped))
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function applyAudioMove(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
|
|
941
|
+
const track = press.hit.track
|
|
942
|
+
if (!track || press.hit.laneIdx === undefined) return noChange(snap, lastProject)
|
|
943
|
+
|
|
944
|
+
// Multi-select move: the whole selection (clips and bars alike) travels as
|
|
945
|
+
// one — same rule as the visual side. A lone audio bar keeps its lane-change
|
|
946
|
+
// behaviour, below.
|
|
947
|
+
if (ctx.selectedIds.length > 1 && ctx.selectedIds.includes(track.id)) {
|
|
948
|
+
return applyGroupMove(ctx, press, point, snap, lastProject, escaped, track, ctx.selectedIds)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const duration = track.end - track.start
|
|
952
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
953
|
+
const rawStart = clamp(track.start + dt, 0, Math.max(0, ctx.totalDuration - duration))
|
|
954
|
+
|
|
955
|
+
// Positive dy is downward, which in the audio stack means a HIGHER lane index
|
|
956
|
+
// (lanes ascend downward) — the opposite of visual tracks. Computed here,
|
|
957
|
+
// ahead of the snap boundaries below, because `destLane` is simultaneously
|
|
958
|
+
// "which lane the drop will land on" and "which lane's peers should snap
|
|
959
|
+
// strong right now" — the same current-lane derivation `itemSnapPoints`
|
|
960
|
+
// needs, so one computation serves both.
|
|
961
|
+
const laneDelta = Math.round((point.y - press.origin.y) / AUDIO_LANE_HEIGHT_PX)
|
|
962
|
+
const destLane = Math.max(0, press.hit.laneIdx + laneDelta)
|
|
963
|
+
|
|
964
|
+
const boundaries = itemSnapPoints(ctx, press, originGuard(escaped, [track.start, track.end]), { laneIdx: destLane })
|
|
965
|
+
const points = snapPointsForSpan(boundaries, duration)
|
|
966
|
+
const snapped = applySnap(rawStart, points, ctx.viewport, snap, ctx.snapConfig)
|
|
967
|
+
const start = clamp(snapped.time, 0, Math.max(0, ctx.totalDuration - duration))
|
|
968
|
+
const guide = Math.abs(start - snapped.time) <= EPSILON
|
|
969
|
+
? spanSnapGuide(snapped, duration, boundaries)
|
|
970
|
+
: null
|
|
971
|
+
|
|
972
|
+
const changes: Partial<AudioTrack> = { start, end: start + duration, lane: destLane }
|
|
973
|
+
// Cross-lane inheritance: a clip dropped onto another lane adopts THAT
|
|
974
|
+
// lane's magnet state, so the row it lands on stays consistent rather than
|
|
975
|
+
// mixing a magnetic clip in with non-magnetic neighbours (or vice versa).
|
|
976
|
+
// Grouped from `press.baseProject` — the project as it stood before this
|
|
977
|
+
// drag — so mid-drag lane membership never sees the dragged clip's own
|
|
978
|
+
// in-flight `lane` changes, and via `groupAudioLanes` so an auto-assigned
|
|
979
|
+
// (lane-less) neighbour is matched the same way the rail groups it, not by
|
|
980
|
+
// a raw `lane` field comparison. Moving onto an EMPTY lane leaves
|
|
981
|
+
// `magnetic` untouched: there is no row to be consistent with yet.
|
|
982
|
+
const destLaneGroup = groupAudioLanes(press.baseProject.audio?.tracks ?? [])
|
|
983
|
+
.find(l => l.laneIndex === destLane)
|
|
984
|
+
const destLaneTracks = (destLaneGroup?.tracks ?? []).filter(t => t.id !== track.id)
|
|
985
|
+
if (destLaneTracks.length > 0) {
|
|
986
|
+
changes.magnetic = destLaneTracks.every(t => t.magnetic === true)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const next = updateAudioTrack(press.baseProject, track.id, changes)
|
|
990
|
+
return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function applyAudioTrim(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
|
|
994
|
+
const track = press.hit.track
|
|
995
|
+
if (!track || !press.hit.edge) return noChange(snap, lastProject)
|
|
996
|
+
|
|
997
|
+
const edge = press.hit.edge === 'in' ? 'start' : 'end'
|
|
998
|
+
const initTime = edge === 'start' ? track.start : track.end
|
|
999
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
1000
|
+
const raw = clamp(initTime + dt, 0, ctx.totalDuration)
|
|
1001
|
+
const fixedEdge = edge === 'start' ? track.end : track.start
|
|
1002
|
+
const snapped = applySnap(
|
|
1003
|
+
raw,
|
|
1004
|
+
itemSnapPoints(ctx, press, [fixedEdge, ...originGuard(escaped, [initTime])]),
|
|
1005
|
+
ctx.viewport, snap, ctx.snapConfig,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
// An audio track is always source-windowed, so it goes straight to
|
|
1009
|
+
// `resizeWindowedItem` — `computeResizedItem` would take its non-video
|
|
1010
|
+
// branch and move the timeline edge alone. This used to reproduce
|
|
1011
|
+
// AudioTrackRow's window arithmetic by hand, with the edge and the window
|
|
1012
|
+
// clamped independently; that is the bug documented on `resizeWindowedItem`,
|
|
1013
|
+
// and a bar trimmed past its media ended up claiming more timeline than it
|
|
1014
|
+
// had audio for.
|
|
1015
|
+
const resized = resizeWindowedItem(track as unknown as Draggable, edge, snapped.time)
|
|
1016
|
+
const changes: Partial<AudioTrack> = {
|
|
1017
|
+
start: resized.start,
|
|
1018
|
+
end: resized.end,
|
|
1019
|
+
inPoint: resized.inPoint,
|
|
1020
|
+
outPoint: resized.outPoint,
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
let next = updateAudioTrack(press.baseProject, track.id, changes)
|
|
1024
|
+
if (ctx.selectedIds.length > 1 && ctx.selectedIds.includes(track.id)) {
|
|
1025
|
+
next = applyResizeDeltaToSelection(next, track.id, ctx.selectedIds, edge, {
|
|
1026
|
+
dStart: edge === 'start' ? resized.start - track.start : 0,
|
|
1027
|
+
dEnd: edge === 'end' ? resized.end - track.end : 0,
|
|
1028
|
+
})
|
|
1029
|
+
}
|
|
1030
|
+
const guide = edgeSnapGuide(snapped, edge === 'start' ? resized.start : resized.end)
|
|
1031
|
+
return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide }
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Drag an audio bar's fade-in or fade-out GRIP. Dragging the fade-IN grip
|
|
1036
|
+
* RIGHT (away from the bar's start) grows `fadeIn`; dragging the fade-OUT
|
|
1037
|
+
* grip LEFT (away from the bar's end) grows `fadeOut` — opposite pixel signs
|
|
1038
|
+
* for the same "away from the corner, into the clip" motion. Dragging either
|
|
1039
|
+
* grip back past its own corner clamps to 0, which is how a fade is removed.
|
|
1040
|
+
*
|
|
1041
|
+
* No snapping — a fade grip has no meaningful timeline boundary to magnetize
|
|
1042
|
+
* to, only its own bar's corner and duration, both already enforced by the
|
|
1043
|
+
* clamp below — so this is simpler than `applyAudioTrim`: no
|
|
1044
|
+
* `itemSnapPoints`/`applySnap`, no guide, `snap` passed straight through
|
|
1045
|
+
* unchanged.
|
|
1046
|
+
*
|
|
1047
|
+
* Clamp: `fadeIn`/`fadeOut` are each floored at 0 (Math.max inside `clamp`),
|
|
1048
|
+
* and the two together may never exceed the bar's own duration — measured
|
|
1049
|
+
* against the OTHER side's current (committed) value, since a single drag
|
|
1050
|
+
* only ever touches its own side.
|
|
1051
|
+
*/
|
|
1052
|
+
function applyAudioFade(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project): Applied {
|
|
1053
|
+
const track = press.hit.track
|
|
1054
|
+
const side = press.hit.side
|
|
1055
|
+
if (!track || !side) return noChange(snap, lastProject)
|
|
1056
|
+
|
|
1057
|
+
const duration = Math.max(0, track.end - track.start)
|
|
1058
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
1059
|
+
const initial = side === 'in' ? (track.fadeIn ?? 0) : (track.fadeOut ?? 0)
|
|
1060
|
+
const otherFade = side === 'in' ? (track.fadeOut ?? 0) : (track.fadeIn ?? 0)
|
|
1061
|
+
const raw = side === 'in' ? initial + dt : initial - dt
|
|
1062
|
+
const value = clamp(raw, 0, Math.max(0, duration - otherFade))
|
|
1063
|
+
|
|
1064
|
+
const changes: Partial<AudioTrack> = side === 'in' ? { fadeIn: value } : { fadeOut: value }
|
|
1065
|
+
const next = updateAudioTrack(press.baseProject, track.id, changes)
|
|
1066
|
+
return { effects: [{ type: 'projectChange', project: next }], snap, lastProject: next, guide: null }
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Drag a keyframe-strip diamond to retime it (plan decision 5).
|
|
1071
|
+
*
|
|
1072
|
+
* The diamond represents every prop that has a point at the SAME
|
|
1073
|
+
* item-relative `t` — the "union of times" decision 2 draws as one merged
|
|
1074
|
+
* strip — so moving it moves EVERY track that has a point there, together,
|
|
1075
|
+
* one `keyframeOps.moveKeyframe` call per prop. `toT` clamps to
|
|
1076
|
+
* `[0, item.end - item.start]`, the item's own span; there is nothing outside
|
|
1077
|
+
* it a keyframe could mean.
|
|
1078
|
+
*
|
|
1079
|
+
* Snapping, unlike the fade grip (which has no meaningful boundary and skips
|
|
1080
|
+
* `applySnap` entirely), DOES apply here: the item's own start/end and every
|
|
1081
|
+
* OTHER keyframe time on the SAME item are real alignments worth a magnet —
|
|
1082
|
+
* converted to ABSOLUTE timeline time (`item.start + t`) since `applySnap`
|
|
1083
|
+
* works in that unit, the same conversion `applyAudioTrim`'s `edgeSnapGuide`
|
|
1084
|
+
* call makes for its own landed value. `fromT`'s own instant is excluded via
|
|
1085
|
+
* `originGuard`, exactly as a trim excludes the edge it started on, so the
|
|
1086
|
+
* diamond isn't recaptured at the position it is leaving; excluded THROUGH
|
|
1087
|
+
* `escaped`, so dragging back allows it to re-snap onto its own start
|
|
1088
|
+
* position once the gesture has pulled clear — same story as a trimmed edge
|
|
1089
|
+
* butting back onto its origin.
|
|
1090
|
+
*
|
|
1091
|
+
* Recomputes from `press.baseProject` every move — like every other
|
|
1092
|
+
* bounded, single-item op (trim, fade) and unlike `move`, which accumulates
|
|
1093
|
+
* — so dragging back and forth cannot compound.
|
|
1094
|
+
*
|
|
1095
|
+
* LANDING ON ANOTHER KEYFRAME MERGES THE TWO. Every other keyframe time is a
|
|
1096
|
+
* STRONG snap target (above), so a drag is actively steered onto them — and
|
|
1097
|
+
* `moveKeyframe` → `normalizeTrack` resolve a same-`t` collision last-wins:
|
|
1098
|
+
* for any prop the two diamonds share, the DRAGGED one's value survives and
|
|
1099
|
+
* the target's is silently dropped. This is deliberate — it reads as "drag
|
|
1100
|
+
* one diamond onto another to collapse them into one" — but it IS a real data
|
|
1101
|
+
* loss for whichever prop's value didn't win, so don't "fix" it into a no-op
|
|
1102
|
+
* or a merge-by-average without a product decision first. It is a normal
|
|
1103
|
+
* undo step like any other keyframe edit, so it is trivially reversible.
|
|
1104
|
+
*/
|
|
1105
|
+
function applyKeyframeMove(
|
|
1106
|
+
ctx: PointerContext,
|
|
1107
|
+
press: Press,
|
|
1108
|
+
point: Point,
|
|
1109
|
+
snap: SnapState,
|
|
1110
|
+
lastProject: Project,
|
|
1111
|
+
escaped: boolean,
|
|
1112
|
+
): Applied {
|
|
1113
|
+
const item = press.hit.item
|
|
1114
|
+
const fromT = press.hit.kfT
|
|
1115
|
+
if (!canKeyframe(item) || fromT === undefined) return noChange(snap, lastProject)
|
|
1116
|
+
|
|
1117
|
+
const duration = Math.max(0, item.end - item.start)
|
|
1118
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
1119
|
+
const raw = clamp(fromT + dt, 0, duration)
|
|
1120
|
+
|
|
1121
|
+
const otherTimes = keyframeUnionTimes(item).filter(t => t !== fromT)
|
|
1122
|
+
const rawPoints: SnapPoint[] = [0, duration, ...otherTimes].map(t => ({ time: item.start + t, strength: 'strong' as const }))
|
|
1123
|
+
const points = snapPointsExcluding(rawPoints, originGuard(escaped, [item.start + fromT]))
|
|
1124
|
+
const snapped = applySnap(item.start + raw, points, ctx.viewport, snap, ctx.snapConfig)
|
|
1125
|
+
const toT = clamp(snapped.time - item.start, 0, duration)
|
|
1126
|
+
|
|
1127
|
+
const props = (item.keyframes ?? [])
|
|
1128
|
+
.filter(track => track.points.some(p => p.t === fromT))
|
|
1129
|
+
.map(track => track.prop)
|
|
1130
|
+
if (props.length === 0) return noChange(snap, lastProject)
|
|
1131
|
+
|
|
1132
|
+
let nextItem = item
|
|
1133
|
+
for (const prop of props) nextItem = moveKeyframe(nextItem, prop, fromT, toT)
|
|
1134
|
+
if (nextItem === item) return noChange(snap, lastProject)
|
|
1135
|
+
|
|
1136
|
+
const next = replaceVisualItem(press.baseProject, item.id, nextItem)
|
|
1137
|
+
const guide = edgeSnapGuide(snapped, item.start + toT)
|
|
1138
|
+
return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide, keyframeLandedT: toT }
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Re-time a caption block — across rows for a lone caption, and along the
|
|
1143
|
+
* timeline for a caption travelling with a wider selection.
|
|
1144
|
+
*
|
|
1145
|
+
* The selection expression is load-bearing. `applyMoveDeltaToSelection` moves
|
|
1146
|
+
* only ids it is handed, and when a drag starts on an UNSELECTED caption the
|
|
1147
|
+
* `selectMany` effect that selects it has not been echoed back by the host yet
|
|
1148
|
+
* — so `ctx.selectedIds` does not contain it on that first move, and the
|
|
1149
|
+
* caption would sit perfectly still while the pointer walked away from it.
|
|
1150
|
+
* Falling back to `[seg.id]` covers both cases in one line: a caption already
|
|
1151
|
+
* in a multi-selection group-moves with everything else, a freshly grabbed one
|
|
1152
|
+
* takes the single-caption path below.
|
|
1153
|
+
*
|
|
1154
|
+
* **Group drags never change lanes.** `applyMoveDeltaToSelection` is
|
|
1155
|
+
* horizontal-only — it shifts times and re-spreads word timings, and `lane`
|
|
1156
|
+
* rides its spread untouched — so a multi-selection travels along the timeline
|
|
1157
|
+
* however far the pointer moves vertically. This is the one place where "drag
|
|
1158
|
+
* any member and the whole selection follows" meets "drag up a row to change
|
|
1159
|
+
* rows", and horizontal-only wins: a group has no single source lane to
|
|
1160
|
+
* measure a lane delta from, and moving every member by the same lane delta
|
|
1161
|
+
* would silently re-stack rows the operator never pointed at.
|
|
1162
|
+
*
|
|
1163
|
+
* Snapping is the ordinary boundary set. A caption hit carries no `trackIdx`
|
|
1164
|
+
* or `laneIdx` — it belongs to no row — so `tieredBoundaries` ranks every clip
|
|
1165
|
+
* and audio boundary WEAK for it, and captions do not snap to each other at
|
|
1166
|
+
* all (that function reads tracks and audio only). Accepted for v1.
|
|
1167
|
+
*
|
|
1168
|
+
* Captions may NOT overlap WITHIN A LANE. On the group path
|
|
1169
|
+
* `applyMoveDeltaToSelection` folds a same-lane caption-neighbour bound in
|
|
1170
|
+
* alongside its timeline-edge bound (see `captionNeighbourBounds` in
|
|
1171
|
+
* multiSelectOps.ts); on the single path the rule is enforced by choosing a
|
|
1172
|
+
* lane the landed span actually fits in (`resolveDropLane`) rather than by
|
|
1173
|
+
* clamping the horizontal landing. Overlap is illegal within a lane because
|
|
1174
|
+
* `activeCaptionSegments` (timeline-core) hands the preview and every render
|
|
1175
|
+
* template EVERY live segment, lane-ascending, and a lane is a z-order with no
|
|
1176
|
+
* vertical offset of its own — so two segments sharing a lane and an instant
|
|
1177
|
+
* paint one straight over the other, in preview AND in the export. Two
|
|
1178
|
+
* segments on DIFFERENT lanes coinciding is the feature rows exist for.
|
|
1179
|
+
* `applyCaptionTrim`, below, has the matching same-lane clamp for a trim.
|
|
1180
|
+
*/
|
|
1181
|
+
function applyCaptionMove(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
|
|
1182
|
+
const seg = press.hit.segment
|
|
1183
|
+
if (!seg || seg.id === undefined) return noChange(snap, lastProject)
|
|
1184
|
+
const id = seg.id
|
|
1185
|
+
const selection = ctx.selectedIds.includes(id) ? ctx.selectedIds : [id]
|
|
1186
|
+
const dragged = { id, start: seg.start, end: seg.end }
|
|
1187
|
+
if (selection.length > 1) {
|
|
1188
|
+
return applyGroupMove(ctx, press, point, snap, lastProject, escaped, dragged, selection)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// The row the press landed in, read off the BAND rather than off the segment.
|
|
1192
|
+
// The two agree by construction (`groupCaptionLanes` files each segment into
|
|
1193
|
+
// the band for its own lane), but the band is what the operator actually
|
|
1194
|
+
// pointed at.
|
|
1195
|
+
const sourceLane = press.hit.captionLane ?? laneOf(seg)
|
|
1196
|
+
|
|
1197
|
+
// Divided by CAPTION_ROW_HEIGHT_PX (40), NOT by the 44px band pitch the
|
|
1198
|
+
// layout actually stacks bands at — the same deliberate shortfall
|
|
1199
|
+
// `applyAudioMove` (AUDIO_LANE_HEIGHT_PX) and `moveItemAcrossTracks`
|
|
1200
|
+
// (VISUAL_ROW_HEIGHT_PX) use, so the drag reaches the neighbouring row a few
|
|
1201
|
+
// pixels before the cursor has fully left the current one.
|
|
1202
|
+
//
|
|
1203
|
+
// `resolveDropLane` then computes `wanted = clamp(sourceLane - laneDelta, 0,
|
|
1204
|
+
// maxLane + 1)`. The MINUS is a deliberate inversion, not a slip: caption
|
|
1205
|
+
// lanes ascend UPWARD on screen (Phase 3 emits the bands in descending lane
|
|
1206
|
+
// order, so lane 0 sits lowest, immediately above the base video row),
|
|
1207
|
+
// whereas audio lanes ascend DOWNWARD — so for captions a downward drag
|
|
1208
|
+
// (positive `laneDelta`) must LOWER the lane number. A sign error here sends
|
|
1209
|
+
// every caption the wrong way.
|
|
1210
|
+
const laneDelta = Math.round((point.y - press.origin.y) / CAPTION_ROW_HEIGHT_PX)
|
|
1211
|
+
|
|
1212
|
+
// No vertical intent at all: byte-for-byte the horizontal-only behaviour,
|
|
1213
|
+
// same-lane neighbour clamp included. This is what an operator who never
|
|
1214
|
+
// drags vertically experiences, so it stays exactly the group path with a
|
|
1215
|
+
// selection of one rather than a second implementation of the same thing.
|
|
1216
|
+
if (laneDelta === 0) {
|
|
1217
|
+
return applyGroupMove(ctx, press, point, snap, lastProject, escaped, dragged, selection)
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ── Vertical intent: a row change, resolved lane-first ──────────────────
|
|
1221
|
+
const duration = seg.end - seg.start
|
|
1222
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
1223
|
+
const boundaries = itemSnapPoints(ctx, press, originGuard(escaped, [seg.start, seg.end]))
|
|
1224
|
+
const snapped = applySnap(seg.start + dt, snapPointsForSpan(boundaries, duration), ctx.viewport, snap, ctx.snapConfig)
|
|
1225
|
+
|
|
1226
|
+
// The horizontal landing is bounded by the timeline's own edges and NOTHING
|
|
1227
|
+
// else — deliberately not by `applyMoveDeltaToSelection`'s neighbour clamp.
|
|
1228
|
+
// A caption that is leaving its row must not be held back by that row's
|
|
1229
|
+
// neighbours, and the row it lands in is chosen to fit it rather than being
|
|
1230
|
+
// clamped against. That is the whole reason a cross-row drag never teleports
|
|
1231
|
+
// a caption sideways hunting for a gap: the search happens in the vertical
|
|
1232
|
+
// axis, exactly as `moveItemAcrossTracks` searches tracks for a clip.
|
|
1233
|
+
//
|
|
1234
|
+
// Same lo/hi `applyMoveDeltaToSelection` computes for a lone caption, minus
|
|
1235
|
+
// its neighbour half — `hi`'s `Math.max(0, …)` floor included, so a caption
|
|
1236
|
+
// already overhanging `totalDuration` (clips trimmed after captioning) is
|
|
1237
|
+
// neither pushed further out nor yanked back in.
|
|
1238
|
+
const delta = clamp(snapped.time - seg.start, -seg.start, Math.max(0, ctx.totalDuration - seg.end))
|
|
1239
|
+
const start = seg.start + delta
|
|
1240
|
+
const end = seg.end + delta
|
|
1241
|
+
|
|
1242
|
+
// Fans out from `wanted` — `wanted, wanted-1, wanted+1, …`, bounded to
|
|
1243
|
+
// `[0, maxLane + 1]` — and takes the first lane the landed span fits in, so
|
|
1244
|
+
// an occupied target row hands off to the nearest free one. `maxLane + 1` is
|
|
1245
|
+
// empty by construction, so the search always terminates there; landing on
|
|
1246
|
+
// it is exactly how a NEW row gets created.
|
|
1247
|
+
const lane = resolveDropLane({
|
|
1248
|
+
segments: press.baseProject.captions?.segments ?? [],
|
|
1249
|
+
seg, sourceLane, laneDelta, start, end,
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
// Word timings are ABSOLUTE seconds, so every word travels with the segment
|
|
1253
|
+
// by the same delta and nothing is respread — the identical rule
|
|
1254
|
+
// `shiftCaptions` (multiSelectOps.ts) applies on the group path. `text` is
|
|
1255
|
+
// never touched: a move is a retime.
|
|
1256
|
+
const patch: Partial<CaptionSegment> = { start, end, lane }
|
|
1257
|
+
if (seg.words) patch.words = seg.words.map(w => ({ ...w, start: w.start + delta, end: w.end + delta }))
|
|
1258
|
+
|
|
1259
|
+
// Same-reference on a true no-op, the contract `change` reads: a drag held
|
|
1260
|
+
// against a clamp in a row it never left emits nothing rather than an
|
|
1261
|
+
// identical project per pointer event.
|
|
1262
|
+
const next = delta === 0 && lane === sourceLane
|
|
1263
|
+
? press.baseProject
|
|
1264
|
+
: replaceCaptionSegment(press.baseProject, id, patch)
|
|
1265
|
+
// Clamped against t=0 or the end of the timeline the caption is NOT where
|
|
1266
|
+
// the magnet asked, so there is nothing to mark — same rule as `applyMove`.
|
|
1267
|
+
const guide = Math.abs(start - snapped.time) <= EPSILON
|
|
1268
|
+
? spanSnapGuide(snapped, duration, boundaries)
|
|
1269
|
+
: null
|
|
1270
|
+
return change(next, lastProject, snapped.state, guide)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** The shortest a caption may be trimmed to. Carried over from the retired DOM
|
|
1274
|
+
* row, which got the same floor from `beginResize`, and relied on twice: a
|
|
1275
|
+
* zero-length caption is unselectable and unreadable, and the click-seek in
|
|
1276
|
+
* `pointerUp` needs every segment to be at least one frame long. */
|
|
1277
|
+
export const CAPTION_MIN_DURATION_S = 0.1
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Drag ONE caption edge. Single-segment by design — multi-caption trim is out
|
|
1281
|
+
* of scope for v1, so a trim never consults the selection at all.
|
|
1282
|
+
*
|
|
1283
|
+
* Only the dragged edge is written. `text` and `words` are never touched: a
|
|
1284
|
+
* pure retime must not respread word timings, which is exactly why the retired
|
|
1285
|
+
* DOM row's commit patch carried the one numeric field and nothing else. Word
|
|
1286
|
+
* timings are absolute seconds, so they stay where the speech actually is; a
|
|
1287
|
+
* respread would silently invent new ones from the segment's new duration.
|
|
1288
|
+
*/
|
|
1289
|
+
function applyCaptionTrim(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
|
|
1290
|
+
const seg = press.hit.segment
|
|
1291
|
+
if (!seg || seg.id === undefined || !press.hit.edge) return noChange(snap, lastProject)
|
|
1292
|
+
|
|
1293
|
+
const edge = press.hit.edge === 'in' ? 'start' : 'end'
|
|
1294
|
+
const initTime = edge === 'start' ? seg.start : seg.end
|
|
1295
|
+
// The nearest OTHER caption SHARING THIS SEGMENT'S LANE on either side — a
|
|
1296
|
+
// segment on another row is not a bound, because captions on different rows
|
|
1297
|
+
// may overlap and all render. `sameLaneNeighbours` (captionLanes.ts) is the
|
|
1298
|
+
// one place that search lives; `seg` is excluded there so it is never its own
|
|
1299
|
+
// neighbour. Absent neighbour reads as unbounded (`±Infinity`) on that side,
|
|
1300
|
+
// the same convention the move clamp uses.
|
|
1301
|
+
//
|
|
1302
|
+
// Press-time, not live — same reasoning `press.baseProject` carries for the
|
|
1303
|
+
// snap boundaries (see the `Press` interface): the host echoes every
|
|
1304
|
+
// `projectChange` back through a re-render, and a neighbour search reading
|
|
1305
|
+
// the live project mid-drag would see this very segment's own current edge
|
|
1306
|
+
// reflected back as if it were a different, moving neighbour.
|
|
1307
|
+
const { prev, next: after } = sameLaneNeighbours(press.baseProject.captions?.segments ?? [], seg)
|
|
1308
|
+
const prevEnd = prev?.end ?? -Infinity
|
|
1309
|
+
const nextStart = after?.start ?? Infinity
|
|
1310
|
+
|
|
1311
|
+
// ── The dragged edge's LEGAL RANGE, computed once and used for everything ──
|
|
1312
|
+
//
|
|
1313
|
+
// The timeline on one side, the duration floor on the other, and a
|
|
1314
|
+
// same-lane caption-neighbour bound on the third: captions may never overlap
|
|
1315
|
+
// within a lane (see the WHY on `applyCaptionMove`), so a trim may not push
|
|
1316
|
+
// its edge across the adjacent SAME-LANE segment's edge either — a segment
|
|
1317
|
+
// on another row bounds nothing, since rows are allowed to overlap and all
|
|
1318
|
+
// render. All three fold into ONE range rather than
|
|
1319
|
+
// clamps in sequence: whichever clamp ran second would undo the first.
|
|
1320
|
+
// Floor-after-bounds let a segment already shorter than the floor escape the
|
|
1321
|
+
// timeline (a 0s–0.05s segment's in edge landed at -0.05s); bounds-after-
|
|
1322
|
+
// floor would break the other invariant instead. As a range, all three hold
|
|
1323
|
+
// at once.
|
|
1324
|
+
//
|
|
1325
|
+
// `hi` on the end side is NOT a flat `totalDuration`. That value is
|
|
1326
|
+
// `contentDuration + Math.max(5, contentDuration * 0.2)` (timeline-model),
|
|
1327
|
+
// derived from clips and audio ONLY, so a caption can outlast it whenever
|
|
1328
|
+
// clips were trimmed or deleted after captioning. Rare, but reachable.
|
|
1329
|
+
// Pinning such a segment at `totalDuration` would yank its end backwards the
|
|
1330
|
+
// moment its handle was touched — a silent destructive shrink triggered by
|
|
1331
|
+
// grabbing a handle, which is worse than the out-of-bounds write this bound
|
|
1332
|
+
// exists to prevent: that one needs a sub-floor segment at a boundary, this
|
|
1333
|
+
// one needs only an overhanging caption and a nudge. Ceiling at the segment's
|
|
1334
|
+
// own end instead, so a trim never pushes a caption FURTHER out than it
|
|
1335
|
+
// already was. A caption inside the timeline is unaffected — for it the max
|
|
1336
|
+
// IS `totalDuration`. `nextStart` then tightens that ceiling further when a
|
|
1337
|
+
// following caption IN THE SAME LANE sits closer than the timeline edge does
|
|
1338
|
+
// (`Infinity` when there is no such caption, so it never loosens the
|
|
1339
|
+
// existing bound).
|
|
1340
|
+
//
|
|
1341
|
+
// `lo` on the start side stays floored at 0 rather than mirroring `hi`
|
|
1342
|
+
// (`Math.min(0, seg.start)`): a negative `start` is unreachable —
|
|
1343
|
+
// `applyMoveDeltaToSelection` clamps a group move at `-minStart` and this
|
|
1344
|
+
// trim clamps at 0, so nothing in the codebase can produce one, and
|
|
1345
|
+
// mirroring would be generality for a state that cannot occur. `prevEnd`
|
|
1346
|
+
// raises that floor when a preceding caption IN THE SAME LANE sits to the
|
|
1347
|
+
// right of t=0 (`-Infinity` when there is no such caption, so it never
|
|
1348
|
+
// lowers it).
|
|
1349
|
+
const lo = edge === 'start' ? Math.max(0, prevEnd) : seg.start + CAPTION_MIN_DURATION_S
|
|
1350
|
+
const hi = edge === 'start'
|
|
1351
|
+
? seg.end - CAPTION_MIN_DURATION_S
|
|
1352
|
+
: Math.min(Math.max(ctx.totalDuration, seg.end), nextStart)
|
|
1353
|
+
|
|
1354
|
+
// An EMPTY range means a sub-floor segment pinned against t=0, the end of
|
|
1355
|
+
// the timeline, or a same-lane neighbouring caption's own edge: there is no legal
|
|
1356
|
+
// position for this edge at all, so the trim declines to move rather than
|
|
1357
|
+
// inventing a least-bad one. Same shape `applyMoveDeltaToSelection` uses for
|
|
1358
|
+
// a group already wider than the timeline (or its own caption-neighbour
|
|
1359
|
+
// bound), which likewise refuses to move rather than pick a side.
|
|
1360
|
+
//
|
|
1361
|
+
// This MUST short-circuit before either clamp below. `clamp` is
|
|
1362
|
+
// `Math.max(lo, Math.min(hi, v))`, which silently returns `lo` when the range
|
|
1363
|
+
// is inverted — so an unguarded clamp would not error, it would quietly place
|
|
1364
|
+
// the edge at a bound that is on the wrong side of the other one.
|
|
1365
|
+
if (lo > hi) return noChange(snap, lastProject)
|
|
1366
|
+
|
|
1367
|
+
const dt = dragDeltaSeconds(ctx, press, point)
|
|
1368
|
+
const raw = clamp(initTime + dt, lo, hi)
|
|
1369
|
+
|
|
1370
|
+
// Same exclusions as every other trim: the opposite edge stays suppressed
|
|
1371
|
+
// for the whole gesture, and only the dragged edge's origin re-arms.
|
|
1372
|
+
const fixedEdge = edge === 'start' ? seg.end : seg.start
|
|
1373
|
+
const snapped = applySnap(
|
|
1374
|
+
raw,
|
|
1375
|
+
itemSnapPoints(ctx, press, [fixedEdge, ...originGuard(escaped, [initTime])]),
|
|
1376
|
+
ctx.viewport, snap, ctx.snapConfig,
|
|
1377
|
+
)
|
|
1378
|
+
// Clamped again after the snap: a magnet can sit outside the range (the
|
|
1379
|
+
// playhead parked past a short caption's floor, say), and a snap is a
|
|
1380
|
+
// suggestion, not a licence to leave it.
|
|
1381
|
+
const landed = clamp(snapped.time, lo, hi)
|
|
1382
|
+
|
|
1383
|
+
// Same-reference on a no-op, the contract `change` reads. Held against a
|
|
1384
|
+
// clamp — dragging further past the floor produces the same edge every move —
|
|
1385
|
+
// this is what keeps a pinned drag from emitting an identical project per
|
|
1386
|
+
// pointer event, and a drag returned to its origin from committing at all.
|
|
1387
|
+
const next = Math.abs(landed - initTime) <= EPSILON
|
|
1388
|
+
? press.baseProject
|
|
1389
|
+
: replaceCaptionSegment(press.baseProject, seg.id, edge === 'start' ? { start: landed } : { end: landed })
|
|
1390
|
+
return change(next, lastProject, snapped.state, edgeSnapGuide(snapped, landed))
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function applyScrub(ctx: PointerContext, point: Point, snap: SnapState, lastProject: Project): Applied {
|
|
1394
|
+
const raw = clamp(xToTime(point.x, ctx.viewport), 0, ctx.totalDuration)
|
|
1395
|
+
const snapped = applySnap(raw, playheadSnapPoints(ctx), ctx.viewport, snap, ctx.snapConfig)
|
|
1396
|
+
return {
|
|
1397
|
+
effects: [{ type: 'seek', time: snapped.time }],
|
|
1398
|
+
snap: snapped.state,
|
|
1399
|
+
lastProject,
|
|
1400
|
+
guide: directSnapGuide(snapped),
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function applyGesture(
|
|
1405
|
+
gesture: GestureKind,
|
|
1406
|
+
ctx: PointerContext,
|
|
1407
|
+
press: Press,
|
|
1408
|
+
point: Point,
|
|
1409
|
+
snap: SnapState,
|
|
1410
|
+
lastProject: Project,
|
|
1411
|
+
escaped: boolean,
|
|
1412
|
+
): Applied {
|
|
1413
|
+
// Before first layout there is no scale, so a pixel delta has no meaning.
|
|
1414
|
+
if (!(ctx.viewport.pxPerSecond > 0)) return noChange(snap, lastProject)
|
|
1415
|
+
switch (gesture) {
|
|
1416
|
+
case 'move': return applyMove(ctx, press, point, snap, lastProject, escaped)
|
|
1417
|
+
case 'trim': return applyTrim(ctx, press, point, snap, lastProject, escaped)
|
|
1418
|
+
case 'roll': return applyRoll(ctx, press, point, snap, lastProject, escaped)
|
|
1419
|
+
case 'slip': return applySlip(ctx, press, point, snap, lastProject)
|
|
1420
|
+
case 'slide': return applySlide(ctx, press, point, snap, lastProject, escaped)
|
|
1421
|
+
case 'audio-move': return applyAudioMove(ctx, press, point, snap, lastProject, escaped)
|
|
1422
|
+
case 'audio-trim': return applyAudioTrim(ctx, press, point, snap, lastProject, escaped)
|
|
1423
|
+
case 'audio-fade': return applyAudioFade(ctx, press, point, snap, lastProject)
|
|
1424
|
+
case 'keyframe-move': return applyKeyframeMove(ctx, press, point, snap, lastProject, escaped)
|
|
1425
|
+
case 'caption-move': return applyCaptionMove(ctx, press, point, snap, lastProject, escaped)
|
|
1426
|
+
case 'caption-trim': return applyCaptionTrim(ctx, press, point, snap, lastProject, escaped)
|
|
1427
|
+
case 'scrub': return applyScrub(ctx, point, snap, lastProject)
|
|
1428
|
+
case 'marquee': return applyMarquee(press, point, snap, lastProject)
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** The live marquee: paint the box, change nothing. The selection is applied
|
|
1433
|
+
* once, on release — a marquee that re-selected on every move would push a
|
|
1434
|
+
* selection change per pointer event through the host for a gesture the user
|
|
1435
|
+
* has not finished expressing. */
|
|
1436
|
+
function applyMarquee(press: Press, point: Point, snap: SnapState, lastProject: Project): Applied {
|
|
1437
|
+
return {
|
|
1438
|
+
effects: [{ type: 'marquee', rect: normalizeRect(press.origin, point) }],
|
|
1439
|
+
snap,
|
|
1440
|
+
lastProject,
|
|
1441
|
+
guide: null,
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// ── Reducer ──────────────────────────────────────────────────────────────
|
|
1446
|
+
|
|
1447
|
+
export interface Transition {
|
|
1448
|
+
state: MachineState
|
|
1449
|
+
effects: PointerEffect[]
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** The guide the surface is currently showing. Only a running gesture has one. */
|
|
1453
|
+
function currentGuide(state: MachineState): SnapGuide | null {
|
|
1454
|
+
return state.kind === 'dragging' ? state.guide : null
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** Append a snap-guide effect when, and only when, the guide actually moves —
|
|
1458
|
+
* or changes tier, which changes how it is drawn.
|
|
1459
|
+
* Same contract as `withCursor`: the host holds the last value, so a silent
|
|
1460
|
+
* transition means "unchanged", never "unknown". */
|
|
1461
|
+
function guideEffects(previous: SnapGuide | null, next: SnapGuide | null, effects: PointerEffect[]): PointerEffect[] {
|
|
1462
|
+
if (previous?.time === next?.time && previous?.strength === next?.strength) return effects
|
|
1463
|
+
return [...effects, { type: 'snapGuide', time: next?.time ?? null, strength: next?.strength ?? null }]
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** Append a cursor effect when, and only when, the cursor actually changes. */
|
|
1467
|
+
function withCursor(state: MachineState, cursor: Cursor, effects: PointerEffect[]): Transition {
|
|
1468
|
+
if (state.cursor === cursor) return { state, effects }
|
|
1469
|
+
return { state: { ...state, cursor }, effects: [...effects, { type: 'cursor', cursor }] }
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
export function pointerReducer(state: MachineState, event: PointerMachineEvent): Transition {
|
|
1473
|
+
if (event.type === 'cancel') {
|
|
1474
|
+
// Carry the current cursor into the idle state first, so `withCursor` sees
|
|
1475
|
+
// the change it has to undo — a cancelled drag must not leave the surface
|
|
1476
|
+
// showing a grabbing hand. A cancelled marquee takes its box with it and
|
|
1477
|
+
// selects nothing, which is what cancel means.
|
|
1478
|
+
const cancelled: PointerEffect[] = state.kind === 'dragging' && state.gesture === 'marquee'
|
|
1479
|
+
? [{ type: 'marquee', rect: null }]
|
|
1480
|
+
: []
|
|
1481
|
+
return withCursor(
|
|
1482
|
+
{ kind: 'idle', cursor: state.cursor },
|
|
1483
|
+
initialMachineState().cursor,
|
|
1484
|
+
guideEffects(currentGuide(state), null, cancelled),
|
|
1485
|
+
)
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const { point, modifiers, ctx } = event
|
|
1489
|
+
|
|
1490
|
+
// Mid-drag moves are the hot path (one per pointer event, all gesture) and
|
|
1491
|
+
// have no use for a hit result, so they take the early exit before one is
|
|
1492
|
+
// computed.
|
|
1493
|
+
if (event.type === 'pointerMove' && state.kind === 'dragging') {
|
|
1494
|
+
// Latched, never cleared: once this gesture has pulled clear of its origin
|
|
1495
|
+
// the boundary it started on stays armed for the rest of the drag.
|
|
1496
|
+
const escaped = state.escaped || hasEscaped(ctx, state.press, point)
|
|
1497
|
+
const applied = applyGesture(state.gesture, ctx, state.press, point, state.snap, state.lastProject, escaped)
|
|
1498
|
+
return {
|
|
1499
|
+
state: {
|
|
1500
|
+
...state,
|
|
1501
|
+
snap: applied.snap,
|
|
1502
|
+
guide: applied.guide,
|
|
1503
|
+
lastProject: applied.lastProject,
|
|
1504
|
+
escaped,
|
|
1505
|
+
keyframeT: applied.keyframeLandedT ?? state.keyframeT,
|
|
1506
|
+
},
|
|
1507
|
+
effects: guideEffects(state.guide, applied.guide, applied.effects),
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// `selectedIds` is merged in here rather than left for each caller to add
|
|
1512
|
+
// to its own `hitTestOptions`, so every host that builds a `PointerContext`
|
|
1513
|
+
// gets keyframe hit-testing for free the moment it has a selection —
|
|
1514
|
+
// exactly like `ctx.selectedIds` itself is already required on the context,
|
|
1515
|
+
// not an opt-in.
|
|
1516
|
+
const hit = hitTest(point, ctx.layout, ctx.viewport, { ...ctx.hitTestOptions, selectedIds: ctx.selectedIds })
|
|
1517
|
+
|
|
1518
|
+
switch (event.type) {
|
|
1519
|
+
case 'pointerDown': {
|
|
1520
|
+
// The ruler, OR a press on the playhead line itself: seek straight away
|
|
1521
|
+
// and keep scrubbing. The ruler is the surface's dedicated playhead
|
|
1522
|
+
// handle — canvas mode dropped the DOM overview bar (see Scrubber.tsx) and
|
|
1523
|
+
// the track area's empty space now belongs to the marquee — and grabbing
|
|
1524
|
+
// the full-height red bar anywhere it is reachable does the same thing
|
|
1525
|
+
// (`grabsPlayhead`). Landing the seek on mousedown rather than mouseup is
|
|
1526
|
+
// what makes the drag continuous.
|
|
1527
|
+
const onRuler = hit.kind === 'ruler'
|
|
1528
|
+
if (onRuler || grabsPlayhead(hit, point, ctx)) {
|
|
1529
|
+
const applied = applyScrub(ctx, point, createSnapState(), ctx.project)
|
|
1530
|
+
// The RULER clears the selection, for the same reason pressing bare
|
|
1531
|
+
// timeline used to: a clip left selected while you scrub somewhere else
|
|
1532
|
+
// means the next keyboard action (split, ripple-delete) hits an item
|
|
1533
|
+
// nowhere near where you are looking. Additive presses are exempt —
|
|
1534
|
+
// shift is how you build a multi-selection, not how you drop one.
|
|
1535
|
+
// Grabbing the playhead itself does NOT clear it (decision D3): it is a
|
|
1536
|
+
// precise nudge in place, not a jump to somewhere else, and dropping a
|
|
1537
|
+
// selection every time you fine-tune the playhead would be hostile.
|
|
1538
|
+
const cleared: PointerEffect[] = (onRuler && !isAdditive(modifiers))
|
|
1539
|
+
? [{ type: 'select', id: null, additive: false }, ...applied.effects]
|
|
1540
|
+
: applied.effects
|
|
1541
|
+
const next: MachineState = {
|
|
1542
|
+
kind: 'dragging',
|
|
1543
|
+
cursor: state.cursor,
|
|
1544
|
+
press: {
|
|
1545
|
+
origin: point,
|
|
1546
|
+
originTime: xToTime(point.x, ctx.viewport),
|
|
1547
|
+
modifiers,
|
|
1548
|
+
hit,
|
|
1549
|
+
baseProject: ctx.project,
|
|
1550
|
+
wasSelected: false,
|
|
1551
|
+
// A scrub never reads these (it uses `playheadSnapPoints`), but a
|
|
1552
|
+
// Press without them would be a Press with a hole in it.
|
|
1553
|
+
snapBoundaries: tieredBoundaries(ctx.project, hit.trackIdx, hit.laneIdx, hit.itemId),
|
|
1554
|
+
},
|
|
1555
|
+
gesture: 'scrub',
|
|
1556
|
+
snap: applied.snap,
|
|
1557
|
+
guide: applied.guide,
|
|
1558
|
+
lastProject: ctx.project,
|
|
1559
|
+
escaped: false,
|
|
1560
|
+
}
|
|
1561
|
+
return withCursor(next, cursorForGesture('scrub'), guideEffects(currentGuide(state), applied.guide, cleared))
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// On an item, or on empty track area: nothing happens yet. Whether this
|
|
1565
|
+
// is a click (select an item / seek) or a drag (edit / marquee) is not
|
|
1566
|
+
// knowable until the pointer moves or lifts. Empty space used to seek on
|
|
1567
|
+
// press; it now waits, because the same press may turn out to be the
|
|
1568
|
+
// start of a selection box and seeking first would move the playhead
|
|
1569
|
+
// every time you drew one.
|
|
1570
|
+
const press: Press = {
|
|
1571
|
+
origin: point,
|
|
1572
|
+
originTime: xToTime(point.x, ctx.viewport),
|
|
1573
|
+
modifiers,
|
|
1574
|
+
hit,
|
|
1575
|
+
baseProject: ctx.project,
|
|
1576
|
+
wasSelected: hit.itemId !== undefined && ctx.selectedIds.includes(hit.itemId),
|
|
1577
|
+
snapBoundaries: tieredBoundaries(ctx.project, hit.trackIdx, hit.laneIdx, hit.itemId),
|
|
1578
|
+
}
|
|
1579
|
+
return withCursor({ kind: 'pressed', cursor: state.cursor, press }, cursorForHit(hit), [])
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
case 'pointerMove': {
|
|
1583
|
+
// Dragging already returned above; only hover and a not-yet-resolved
|
|
1584
|
+
// press reach here.
|
|
1585
|
+
if (state.kind !== 'pressed') {
|
|
1586
|
+
// Over the playhead line the cursor is the scrub cursor, so hovering the
|
|
1587
|
+
// red bar tells you it is grabbable before you press it.
|
|
1588
|
+
const cursor = grabsPlayhead(hit, point, ctx) ? 'ew-resize' : cursorForHit(hit)
|
|
1589
|
+
return withCursor(state, cursor, [])
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
if (travelled(point, state.press.origin) < DRAG_THRESHOLD_PX) return { state, effects: [] }
|
|
1593
|
+
const gesture = resolveGesture(state.press.hit, state.press.modifiers)
|
|
1594
|
+
if (gesture === null) return { state, effects: [] }
|
|
1595
|
+
// Grabbing an UNSELECTED clip, bar or caption to move it makes it the
|
|
1596
|
+
// selection first, so it is the thing that moves and the thing left
|
|
1597
|
+
// selected after — matching every NLE. A drag that starts on an item
|
|
1598
|
+
// already in a multi-selection is a group move and leaves the selection
|
|
1599
|
+
// untouched. Note this effect only reaches `ctx.selectedIds` on the NEXT
|
|
1600
|
+
// event, which is why each move applier has to cope with its own dragged
|
|
1601
|
+
// id being absent from it (see `applyCaptionMove`).
|
|
1602
|
+
const draggedId = state.press.hit.itemId
|
|
1603
|
+
const selectFirst: PointerEffect[] =
|
|
1604
|
+
(gesture === 'move' || gesture === 'audio-move' || gesture === 'caption-move')
|
|
1605
|
+
&& draggedId !== undefined && !ctx.selectedIds.includes(draggedId)
|
|
1606
|
+
? [{ type: 'selectMany', ids: [draggedId], additive: false }]
|
|
1607
|
+
: []
|
|
1608
|
+
const escaped = hasEscaped(ctx, state.press, point)
|
|
1609
|
+
const applied = applyGesture(gesture, ctx, state.press, point, createSnapState(), state.press.baseProject, escaped)
|
|
1610
|
+
const next: MachineState = {
|
|
1611
|
+
kind: 'dragging',
|
|
1612
|
+
cursor: state.cursor,
|
|
1613
|
+
press: state.press,
|
|
1614
|
+
gesture,
|
|
1615
|
+
snap: applied.snap,
|
|
1616
|
+
guide: applied.guide,
|
|
1617
|
+
lastProject: applied.lastProject,
|
|
1618
|
+
escaped,
|
|
1619
|
+
keyframeT: applied.keyframeLandedT,
|
|
1620
|
+
}
|
|
1621
|
+
return withCursor(next, cursorForGesture(gesture), guideEffects(null, applied.guide, [...selectFirst, ...applied.effects]))
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
case 'pointerUp': {
|
|
1625
|
+
if (state.kind === 'pressed') {
|
|
1626
|
+
const { hit: pressed, wasSelected } = state.press
|
|
1627
|
+
const effects: PointerEffect[] = []
|
|
1628
|
+
if (pressed.itemId !== undefined) {
|
|
1629
|
+
const additive = isAdditive(modifiers)
|
|
1630
|
+
effects.push({ type: 'select', id: pressed.itemId, additive })
|
|
1631
|
+
// VisualTrackRow seeks on a plain click that changes the selection;
|
|
1632
|
+
// AudioTrackRow never seeks. Both use the raw, unsnapped click time.
|
|
1633
|
+
const visual = pressed.kind === 'item-body' || pressed.kind === 'item-edge'
|
|
1634
|
+
if (visual && !additive && !wasSelected) {
|
|
1635
|
+
effects.push({ type: 'seek', time: clamp(xToTime(point.x, ctx.viewport), 0, ctx.totalDuration) })
|
|
1636
|
+
} else if (isCaptionHit(pressed) && pressed.segment && !additive && !wasSelected) {
|
|
1637
|
+
// A caption seeks to its OWN start, not to the clicked time —
|
|
1638
|
+
// the preview only offers drag handles for the segment active at
|
|
1639
|
+
// the playhead, so selecting one without seeking into it selects
|
|
1640
|
+
// something the preview cannot yet act on.
|
|
1641
|
+
//
|
|
1642
|
+
// And half a frame IN, not to `start` itself. `CaptionPreview`
|
|
1643
|
+
// snaps the clock to the frame grid (`t = Math.round(currentTime *
|
|
1644
|
+
// fps) / fps`) before the templates' own `t >= start && t < end`
|
|
1645
|
+
// test, and caption starts are arbitrary floats out of Whisper.
|
|
1646
|
+
// Seeking to exactly `start` therefore rounds DOWN into the
|
|
1647
|
+
// PREVIOUS segment whenever `start * fps` has a fractional part
|
|
1648
|
+
// below 0.5 — roughly half of all segments (start 3.44 at 30fps →
|
|
1649
|
+
// frame 103 → t 3.4333 < 3.44). `start + 0.5 / fps` puts the
|
|
1650
|
+
// snapped `t` in [start, start + 1/fps), always inside, and
|
|
1651
|
+
// `CAPTION_MIN_DURATION_S` guarantees no segment is under a frame.
|
|
1652
|
+
//
|
|
1653
|
+
// `ctx.fps` comes from `project.settings?.fps ?? 30`, which lets an
|
|
1654
|
+
// explicit `0` (falsy, but not nullish) through untouched — and
|
|
1655
|
+
// `0.5 / 0` is `Infinity`, which the clamp below parks at
|
|
1656
|
+
// `totalDuration` instead of inside the segment. Guarded here too.
|
|
1657
|
+
const fps = Number.isFinite(ctx.fps) && ctx.fps > 0 ? ctx.fps : 30
|
|
1658
|
+
effects.push({ type: 'seek', time: clamp(pressed.segment.start + 0.5 / fps, 0, ctx.totalDuration) })
|
|
1659
|
+
}
|
|
1660
|
+
} else if (isEmptyHit(pressed)) {
|
|
1661
|
+
// A click on empty track area that never became a marquee: seek and
|
|
1662
|
+
// drop the selection, which is what pressing there did before the
|
|
1663
|
+
// marquee took the drag half of this gesture.
|
|
1664
|
+
if (!isAdditive(modifiers)) effects.push({ type: 'select', id: null, additive: false })
|
|
1665
|
+
effects.push({ type: 'seek', time: clamp(xToTime(point.x, ctx.viewport), 0, ctx.totalDuration) })
|
|
1666
|
+
}
|
|
1667
|
+
return withCursor({ kind: 'idle', cursor: state.cursor }, cursorForHit(hit), effects)
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
if (state.kind === 'dragging') {
|
|
1671
|
+
const effects: PointerEffect[] = []
|
|
1672
|
+
if (state.gesture === 'marquee') {
|
|
1673
|
+
// Take the box down first, then apply what it caught. A marquee never
|
|
1674
|
+
// touches the project, so it never commits.
|
|
1675
|
+
const rect = normalizeRect(state.press.origin, point)
|
|
1676
|
+
effects.push({ type: 'marquee', rect: null })
|
|
1677
|
+
effects.push({
|
|
1678
|
+
type: 'selectMany',
|
|
1679
|
+
ids: itemsInRect(rect, ctx.layout, ctx.viewport),
|
|
1680
|
+
additive: isAdditive(state.press.modifiers) || isAdditive(modifiers),
|
|
1681
|
+
})
|
|
1682
|
+
return withCursor(
|
|
1683
|
+
{ kind: 'idle', cursor: state.cursor },
|
|
1684
|
+
cursorForHit(hit),
|
|
1685
|
+
guideEffects(state.guide, null, effects),
|
|
1686
|
+
)
|
|
1687
|
+
}
|
|
1688
|
+
// A scrub has nothing to persist, and a gesture whose every move clamped
|
|
1689
|
+
// to a no-op has nothing either — committing an unchanged project would
|
|
1690
|
+
// make the host write the file for a gesture that did nothing.
|
|
1691
|
+
if (state.gesture !== 'scrub' && state.lastProject !== state.press.baseProject) {
|
|
1692
|
+
// Snap any magnetic audio lane gapless on RELEASE, not mid-drag —
|
|
1693
|
+
// `reflowMagneticLanes` is idempotent and only ever touches
|
|
1694
|
+
// magnetic audio lanes, so visual/caption gestures and non-magnetic
|
|
1695
|
+
// audio are unaffected; keeping it off the transient
|
|
1696
|
+
// `projectChange` frames is what keeps the drag itself smooth.
|
|
1697
|
+
let committed = reflowMagneticLanes(state.lastProject)
|
|
1698
|
+
// A caption that was alone in its row leaves a HOLE lane behind it.
|
|
1699
|
+
// Collapse it here — after the "did anything change" gate, inside the
|
|
1700
|
+
// one commit — so a cross-row drag renumbers exactly once and lands
|
|
1701
|
+
// as a single undo entry.
|
|
1702
|
+
//
|
|
1703
|
+
// The mid-drag `projectChange` frames deliberately keep the
|
|
1704
|
+
// UN-normalized project: the hole lane is what keeps
|
|
1705
|
+
// `groupCaptionLanes` emitting an empty band there, and that band is
|
|
1706
|
+
// what stops the whole timeline jumping a row height under the
|
|
1707
|
+
// pointer half way through the gesture.
|
|
1708
|
+
if (state.gesture === 'caption-move') {
|
|
1709
|
+
const normalized = normalizeCaptionLanes(committed.captions)
|
|
1710
|
+
if (normalized !== committed.captions) committed = { ...committed, captions: normalized }
|
|
1711
|
+
}
|
|
1712
|
+
effects.push({ type: 'commit', project: committed })
|
|
1713
|
+
// CapCut-correct retime: the diamond you dragged is the diamond you
|
|
1714
|
+
// have selected, even though its `t` just changed underneath the
|
|
1715
|
+
// selection's old value. Only follows when the dragged diamond WAS
|
|
1716
|
+
// the selected one (`ctx.selectedKeyframe` matches this gesture's
|
|
1717
|
+
// press-time itemId + fromT) — dragging some OTHER, unselected
|
|
1718
|
+
// diamond must not steal the selection, and a menu/Delete-driven
|
|
1719
|
+
// removal (not a retime) still goes through the host's own
|
|
1720
|
+
// clearing paths untouched by this branch. Gated on the same
|
|
1721
|
+
// "did anything actually change" check above, so a drag that
|
|
1722
|
+
// never crossed a real move never touches the selection either.
|
|
1723
|
+
if (state.gesture === 'keyframe-move' && state.keyframeT !== undefined) {
|
|
1724
|
+
const { itemId, kfT: fromT } = state.press.hit
|
|
1725
|
+
const sel = ctx.selectedKeyframe
|
|
1726
|
+
if (itemId !== undefined && fromT !== undefined && sel && sel.itemId === itemId && sel.t === fromT) {
|
|
1727
|
+
effects.push({ type: 'selectKeyframe', itemId, t: state.keyframeT })
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
return withCursor(
|
|
1732
|
+
{ kind: 'idle', cursor: state.cursor },
|
|
1733
|
+
cursorForHit(hit),
|
|
1734
|
+
guideEffects(state.guide, null, effects),
|
|
1735
|
+
)
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
return withCursor(state, cursorForHit(hit), [])
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
case 'doubleClick': {
|
|
1742
|
+
// A diamond selects itself. Checked first: a 'keyframe' hit also carries an
|
|
1743
|
+
// itemId, so the generic branch below would otherwise swallow it.
|
|
1744
|
+
if (hit.kind === 'keyframe' && hit.itemId !== undefined && hit.kfT !== undefined) {
|
|
1745
|
+
return { state, effects: [{ type: 'selectKeyframe', itemId: hit.itemId, t: hit.kfT }] }
|
|
1746
|
+
}
|
|
1747
|
+
// Key the SELECTED element, and only when the click landed ON it. Checked
|
|
1748
|
+
// against `ctx.selectedIds` rather than "is there a selection" so a
|
|
1749
|
+
// double-click on a different clip never keys the selected one.
|
|
1750
|
+
if (
|
|
1751
|
+
(hit.kind === 'item-body' || hit.kind === 'item-edge') &&
|
|
1752
|
+
hit.item && hit.itemId !== undefined &&
|
|
1753
|
+
ctx.selectedIds.includes(hit.itemId) &&
|
|
1754
|
+
canKeyframe(hit.item)
|
|
1755
|
+
) {
|
|
1756
|
+
const item = hit.item
|
|
1757
|
+
const localT = clamp(hit.t - item.start, 0, Math.max(0, item.end - item.start))
|
|
1758
|
+
|
|
1759
|
+
// WHICH props get keyed depends on the item, and this used to be a
|
|
1760
|
+
// hand-maintained five-prop constant here. `transformProps` is the
|
|
1761
|
+
// shared rule (see its doc comment for why a fixed list breaks in both
|
|
1762
|
+
// directions): keying `scaleX`/`scaleY` on a uniform overlay freezes its
|
|
1763
|
+
// zoom, and keying `scale` on a per-axis one does nothing at all,
|
|
1764
|
+
// because a per-axis value shadows the uniform one in the resolver.
|
|
1765
|
+
// OverlayInspector's header keyframe-all action reads the same function.
|
|
1766
|
+
//
|
|
1767
|
+
// Every value read off the ORIGINAL item BEFORE threading, so one prop's
|
|
1768
|
+
// write cannot perturb another's sample. Same rule OverlayInspector's
|
|
1769
|
+
// header diamond follows.
|
|
1770
|
+
const sampled = transformProps(item).map(prop => [prop, valueAt(item, prop, localT)] as const)
|
|
1771
|
+
let nextItem = item
|
|
1772
|
+
for (const [prop, value] of sampled) {
|
|
1773
|
+
nextItem = setKeyframe(enableKeyframing(nextItem, prop, localT), prop, localT, value)
|
|
1774
|
+
}
|
|
1775
|
+
if (nextItem === item) return { state, effects: [] }
|
|
1776
|
+
|
|
1777
|
+
// `ctx.project` is the ONLY project reference in scope here. The
|
|
1778
|
+
// `doubleClick` case is a top-level branch of the reducer's switch — it is
|
|
1779
|
+
// not nested under a `pressed`/`dragging` state, so there is no `press`
|
|
1780
|
+
// and no `baseProject`. (`baseProject` is a drag-only concept: recompute
|
|
1781
|
+
// from press-time so a back-and-forth drag cannot compound. A discrete
|
|
1782
|
+
// double-click never presses, so it is meaningless here.)
|
|
1783
|
+
const next = replaceVisualItem(ctx.project, item.id, { keyframes: nextItem.keyframes })
|
|
1784
|
+
return { state, effects: [
|
|
1785
|
+
{ type: 'projectChange', project: next },
|
|
1786
|
+
{ type: 'commit', project: next },
|
|
1787
|
+
] }
|
|
1788
|
+
}
|
|
1789
|
+
// A clip or bar opens its inspector. Timeline background does nothing:
|
|
1790
|
+
// double-clicking it used to place the A/B range markers, which are gone.
|
|
1791
|
+
if (hit.itemId !== undefined) {
|
|
1792
|
+
// A caption has no inspector — it opens its text for editing instead.
|
|
1793
|
+
if (isCaptionHit(hit)) return { state, effects: [{ type: 'editCaption', id: hit.itemId }] }
|
|
1794
|
+
// 'keyframe' is visual too — a diamond only ever exists on an overlay
|
|
1795
|
+
// item (never an audio track). A well-formed diamond hit already
|
|
1796
|
+
// returned above; this only catches one missing its `kfT`, which still
|
|
1797
|
+
// belongs to that overlay rather than to any audio bar.
|
|
1798
|
+
const target = hit.kind === 'item-body' || hit.kind === 'item-edge' || hit.kind === 'keyframe' ? 'visual' : 'audio'
|
|
1799
|
+
return { state, effects: [{ type: 'inspect', target, id: hit.itemId }] }
|
|
1800
|
+
}
|
|
1801
|
+
return { state, effects: [] }
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// ── Imperative wrapper ───────────────────────────────────────────────────
|
|
1807
|
+
|
|
1808
|
+
export interface PointerMachine {
|
|
1809
|
+
readonly state: MachineState
|
|
1810
|
+
dispatch(event: PointerMachineEvent): PointerEffect[]
|
|
1811
|
+
reset(): void
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/** A reducer plus the one mutable slot the component needs. Kept trivial on
|
|
1815
|
+
* purpose: all the behaviour is in `pointerReducer`, which tests drive
|
|
1816
|
+
* directly. */
|
|
1817
|
+
export function createPointerMachine(initial: MachineState = initialMachineState()): PointerMachine {
|
|
1818
|
+
let state = initial
|
|
1819
|
+
return {
|
|
1820
|
+
get state() { return state },
|
|
1821
|
+
dispatch(event) {
|
|
1822
|
+
const transition = pointerReducer(state, event)
|
|
1823
|
+
state = transition.state
|
|
1824
|
+
return transition.effects
|
|
1825
|
+
},
|
|
1826
|
+
reset() { state = initialMachineState() },
|
|
1827
|
+
}
|
|
1828
|
+
}
|