@bycrux/editor 0.12.0 → 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 +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- 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 +586 -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 +6 -2
- 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 +2202 -251
- 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/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,3358 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* SP5 T5 — every gesture the canvas timeline supports, driven as pure data.
|
|
4
|
+
*
|
|
5
|
+
* The machine takes injected points and context and returns effects, so these
|
|
6
|
+
* tests are the real coverage for pointer behaviour: no canvas, no jsdom
|
|
7
|
+
* events, no timers. `TimelineCanvas.test.tsx` covers only the wiring.
|
|
8
|
+
*
|
|
9
|
+
* Fixture (100px/second, no scroll → x = t × 100). Row Y's are derived from
|
|
10
|
+
* `computeTimelineLayout` rather than written out, so a change to the rendered
|
|
11
|
+
* row heights (as the frames/waveform split brought) retargets these gestures
|
|
12
|
+
* instead of silently aiming them at the gaps between rows:
|
|
13
|
+
*
|
|
14
|
+
* row track 1 — o0 (overlay) 2s–4s
|
|
15
|
+
* row track 0 — c0 0s–5s, c1 5s–10s (both video, 20s sources)
|
|
16
|
+
* lane audio a0 1s–6s, the bar inset inside the lane
|
|
17
|
+
*
|
|
18
|
+
* Snap boundaries therefore are {0, 5, 10, 2, 4, 1, 6}, and a gesture's own two
|
|
19
|
+
* boundaries are excluded from its magnets.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import type { Project } from '../../../../types'
|
|
23
|
+
import { trackFor, valueAt } from '../../../keyframeOps'
|
|
24
|
+
import { CAPTION_ROW_HEIGHT_PX, ROW_GAP_PX, VISUAL_ROW_HEIGHT_PX, computeDerivedTiming, trackItems } from '../../timeline-model'
|
|
25
|
+
import { AUDIO_ITEM_INSET_PX, computeTimelineLayout } from '../draw'
|
|
26
|
+
import {
|
|
27
|
+
CAPTION_MIN_DURATION_S,
|
|
28
|
+
NO_MODIFIERS,
|
|
29
|
+
createPointerMachine,
|
|
30
|
+
cursorForHit,
|
|
31
|
+
initialMachineState,
|
|
32
|
+
isAdditive,
|
|
33
|
+
pointerReducer,
|
|
34
|
+
resolveGesture,
|
|
35
|
+
type Modifiers,
|
|
36
|
+
type PointerContext,
|
|
37
|
+
type PointerEffect,
|
|
38
|
+
} from '../pointer-machine'
|
|
39
|
+
import { FADE_GRIP_ZONE_HEIGHT_PX, hitTest } from '../hit-test'
|
|
40
|
+
import type { SnapConfig } from '../snap'
|
|
41
|
+
import { xToTime, type Viewport } from '../viewport'
|
|
42
|
+
|
|
43
|
+
const VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
|
|
44
|
+
|
|
45
|
+
/** Zeroes every capture/release radius, so `applySnap` never magnetizes a
|
|
46
|
+
* candidate to a boundary (see `applySnap`'s `dist < bestStrongDist` gate —
|
|
47
|
+
* `bestStrongDist` starts at `attractPx`, so 0 means no distance is ever
|
|
48
|
+
* `< 0`). Used by the ripple-insert tests below, which need the dropped
|
|
49
|
+
* item to land at an EXACT, hand-computed time rather than wherever the
|
|
50
|
+
* magnet happens to pull it. */
|
|
51
|
+
const ZERO_SNAP: SnapConfig = { attractPx: 0, releasePx: 0, weakAttractPx: 0, weakReleasePx: 0 }
|
|
52
|
+
|
|
53
|
+
function baseProject(): Project {
|
|
54
|
+
return {
|
|
55
|
+
id: 'p',
|
|
56
|
+
tracks: [
|
|
57
|
+
{
|
|
58
|
+
id: 'trk-0',
|
|
59
|
+
items: [
|
|
60
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
|
|
61
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
{ id: 'trk-1', items: [{ id: 'o0', type: 'overlay', start: 2, end: 4 }] },
|
|
65
|
+
],
|
|
66
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
|
|
67
|
+
} as unknown as Project
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Same layout as `baseProject`, but o0 (the overlay) is keyframed: offsetX at
|
|
72
|
+
* t=0.5 and t=1.5, opacity at t=0.5 only — so the strip's union of times
|
|
73
|
+
* (plan decision 2) is {0.5, 1.5}. At 100px/s and o0.start=2, those land at
|
|
74
|
+
* x=250 (a diamond BOTH props share) and x=350 (offsetX alone).
|
|
75
|
+
*/
|
|
76
|
+
function keyframedProject(): Project {
|
|
77
|
+
return {
|
|
78
|
+
id: 'p',
|
|
79
|
+
tracks: [
|
|
80
|
+
{
|
|
81
|
+
id: 'trk-0',
|
|
82
|
+
items: [
|
|
83
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
|
|
84
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'trk-1',
|
|
89
|
+
items: [{
|
|
90
|
+
id: 'o0', type: 'overlay', start: 2, end: 4,
|
|
91
|
+
keyframes: [
|
|
92
|
+
{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }, { t: 1.5, value: 10 }] },
|
|
93
|
+
{ prop: 'opacity', points: [{ t: 0.5, value: 1 }] },
|
|
94
|
+
],
|
|
95
|
+
}],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
|
|
99
|
+
} as unknown as Project
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Same layout as `baseProject`, but `tracks` in the pre-T6 legacy shape (a
|
|
103
|
+
* bare array of item arrays, no track ids). Every reader in the package
|
|
104
|
+
* tolerates this — but `moveItemAcrossTracks`'s two callers used to pass
|
|
105
|
+
* `project.tracks` straight through, so this is what an editor genuinely
|
|
106
|
+
* reaches for when server-side shape normalization hasn't run yet (e.g. the
|
|
107
|
+
* SSE stream's initial frame reads project.json off disk unmigrated). */
|
|
108
|
+
function _legacyProject(): Project {
|
|
109
|
+
return {
|
|
110
|
+
id: 'p',
|
|
111
|
+
tracks: [
|
|
112
|
+
[
|
|
113
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
|
|
114
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
|
|
115
|
+
],
|
|
116
|
+
[{ id: 'o0', type: 'overlay', start: 2, end: 4 }],
|
|
117
|
+
],
|
|
118
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
|
|
119
|
+
} as unknown as Project
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Same layout as `baseProject`, but track 1's item is VIDEO-kind (`o0`
|
|
124
|
+
* keeps its id, start and end — only `type`/`src` change) instead of
|
|
125
|
+
* overlay. Used ONLY by the cross-track-move tests below that drag a clip
|
|
126
|
+
* ACROSS the track-0/track-1 boundary: those tests exist to exercise the
|
|
127
|
+
* search mechanics (accumulation, collision tolerance, pruning), and the
|
|
128
|
+
* kind-lock (`moveItemAcrossTracks`'s `kindOk`) correctly refuses to let a
|
|
129
|
+
* video clip land on an overlay-only track — so a same-kind neighbour is
|
|
130
|
+
* what lets the drag actually happen. Every other test in this file still
|
|
131
|
+
* exercises the real (video + overlay) `baseProject`.
|
|
132
|
+
*/
|
|
133
|
+
function sameKindProject(): Project {
|
|
134
|
+
return {
|
|
135
|
+
id: 'p',
|
|
136
|
+
tracks: [
|
|
137
|
+
{
|
|
138
|
+
id: 'trk-0',
|
|
139
|
+
items: [
|
|
140
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
|
|
141
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{ id: 'trk-1', items: [{ id: 'o0', type: 'video', src: 'o0.mp4', start: 2, end: 4, inPoint: 0, outPoint: 2, sourceDuration: 20 }] },
|
|
145
|
+
],
|
|
146
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
|
|
147
|
+
} as unknown as Project
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `sameKindProject`, legacy array-of-arrays shape — the same-kind sibling of
|
|
151
|
+
* `legacyProject`, for the T6-regression cross-track test. */
|
|
152
|
+
function sameKindLegacyProject(): Project {
|
|
153
|
+
return {
|
|
154
|
+
id: 'p',
|
|
155
|
+
tracks: [
|
|
156
|
+
[
|
|
157
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
|
|
158
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
|
|
159
|
+
],
|
|
160
|
+
[{ id: 'o0', type: 'video', src: 'o0.mp4', start: 2, end: 4, inPoint: 0, outPoint: 2, sourceDuration: 20 }],
|
|
161
|
+
],
|
|
162
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
|
|
163
|
+
} as unknown as Project
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function makeContext(overrides: Partial<PointerContext> = {}): PointerContext {
|
|
167
|
+
const project = overrides.project ?? baseProject()
|
|
168
|
+
const { snapBoundaries, totalDuration } = computeDerivedTiming(project)
|
|
169
|
+
return {
|
|
170
|
+
project,
|
|
171
|
+
layout: computeTimelineLayout(project),
|
|
172
|
+
viewport: VIEWPORT,
|
|
173
|
+
selectedIds: [],
|
|
174
|
+
snapBoundaries,
|
|
175
|
+
totalDuration,
|
|
176
|
+
rippleMode: false,
|
|
177
|
+
playheadTime: 0,
|
|
178
|
+
fps: 30,
|
|
179
|
+
...overrides,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function mods(over: Partial<Modifiers> = {}): Modifiers {
|
|
184
|
+
return { ...NO_MODIFIERS, ...over }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Drive a gesture through the imperative wrapper, collecting every effect. */
|
|
188
|
+
class Driver {
|
|
189
|
+
machine = createPointerMachine()
|
|
190
|
+
effects: PointerEffect[] = []
|
|
191
|
+
constructor(public ctx: PointerContext) {}
|
|
192
|
+
|
|
193
|
+
down(x: number, y: number, modifiers = NO_MODIFIERS) {
|
|
194
|
+
this.effects = this.machine.dispatch({ type: 'pointerDown', point: { x, y }, modifiers, ctx: this.ctx })
|
|
195
|
+
return this.effects
|
|
196
|
+
}
|
|
197
|
+
move(x: number, y: number, modifiers = NO_MODIFIERS) {
|
|
198
|
+
this.effects = this.machine.dispatch({ type: 'pointerMove', point: { x, y }, modifiers, ctx: this.ctx })
|
|
199
|
+
return this.effects
|
|
200
|
+
}
|
|
201
|
+
up(x: number, y: number, modifiers = NO_MODIFIERS) {
|
|
202
|
+
this.effects = this.machine.dispatch({ type: 'pointerUp', point: { x, y }, modifiers, ctx: this.ctx })
|
|
203
|
+
return this.effects
|
|
204
|
+
}
|
|
205
|
+
doubleClick(x: number, y: number, modifiers = NO_MODIFIERS) {
|
|
206
|
+
this.effects = this.machine.dispatch({ type: 'doubleClick', point: { x, y }, modifiers, ctx: this.ctx })
|
|
207
|
+
return this.effects
|
|
208
|
+
}
|
|
209
|
+
cancel() {
|
|
210
|
+
this.effects = this.machine.dispatch({ type: 'cancel' })
|
|
211
|
+
return this.effects
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function of<T extends PointerEffect['type']>(effects: PointerEffect[], type: T) {
|
|
216
|
+
return effects.filter(e => e.type === type) as Extract<PointerEffect, { type: T }>[]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function lastProjectChange(effects: PointerEffect[]): Project {
|
|
220
|
+
const changes = of(effects, 'projectChange')
|
|
221
|
+
expect(changes.length).toBeGreaterThan(0)
|
|
222
|
+
return changes[changes.length - 1].project
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function visual(project: Project, id: string) {
|
|
226
|
+
return trackItems(project).flat().find(i => i.id === id)!
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function audio(project: Project, id: string) {
|
|
230
|
+
return (project.audio?.tracks ?? []).find(t => t.id === id)!
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function trackIndexOf(project: Project, id: string): number {
|
|
234
|
+
return trackItems(project).findIndex(t => t.some(i => i.id === id))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Coordinates the fixture makes meaningful. The Y of each row comes from the
|
|
238
|
+
// layout the painter itself computes, so these stay on target at any row height.
|
|
239
|
+
const LAYOUT = computeTimelineLayout(baseProject())
|
|
240
|
+
const rowMidY = (trackIdx: number) => {
|
|
241
|
+
const row = LAYOUT.rows.find(r => r.trackIdx === trackIdx)!
|
|
242
|
+
return Math.round(row.y + row.height / 2)
|
|
243
|
+
}
|
|
244
|
+
const BASE_Y = rowMidY(0) // track 0, the tall base row
|
|
245
|
+
const OVERLAY_Y = rowMidY(1) // track 1, stacked above it
|
|
246
|
+
const LANE_Y = Math.round(LAYOUT.lanes[0].y + LAYOUT.lanes[0].height / 2)
|
|
247
|
+
|
|
248
|
+
const C0_BODY = { x: 250, y: BASE_Y }
|
|
249
|
+
const C0_OUT_EDGE = { x: 495, y: BASE_Y }
|
|
250
|
+
const C1_BODY = { x: 750, y: BASE_Y }
|
|
251
|
+
const C1_IN_EDGE = { x: 505, y: BASE_Y }
|
|
252
|
+
const A0_BODY = { x: 300, y: LANE_Y }
|
|
253
|
+
const A0_OUT_EDGE = { x: 597, y: LANE_Y }
|
|
254
|
+
// The fade grips live in the TOP `FADE_GRIP_ZONE_HEIGHT_PX` of the bar, not
|
|
255
|
+
// its vertical centre — `LANE_Y` above is no good for them. a0 spans 1s–6s
|
|
256
|
+
// with no fade set, so its corners (and therefore its un-faded grips) sit at
|
|
257
|
+
// x=100 (start) and x=600 (end).
|
|
258
|
+
const FADE_GRIP_Y = Math.round(LAYOUT.lanes[0].y + AUDIO_ITEM_INSET_PX + FADE_GRIP_ZONE_HEIGHT_PX / 2)
|
|
259
|
+
const A0_FADE_IN_GRIP = { x: 100, y: FADE_GRIP_Y }
|
|
260
|
+
const A0_FADE_OUT_GRIP = { x: 600, y: FADE_GRIP_Y }
|
|
261
|
+
// Keyframe diamonds live in the BOTTOM `KEYFRAME_STRIP_ZONE_HEIGHT_PX` of the
|
|
262
|
+
// row, not its centre — same reasoning `FADE_GRIP_Y` needed for the fade
|
|
263
|
+
// grip's own top zone. `keyframedProject`'s o0 (2s–4s @ 100px/s) has diamonds
|
|
264
|
+
// at x=250 (offsetX + opacity, shared) and x=350 (offsetX alone).
|
|
265
|
+
const OVERLAY_ROW = LAYOUT.rows.find(r => r.trackIdx === 1)!
|
|
266
|
+
const KEYFRAME_ZONE_Y = OVERLAY_ROW.y + OVERLAY_ROW.height - 2
|
|
267
|
+
const O0_KEYFRAME_SHARED = { x: 250, y: KEYFRAME_ZONE_Y } // t=0.5 — offsetX + opacity
|
|
268
|
+
const O0_KEYFRAME_SOLO = { x: 350, y: KEYFRAME_ZONE_Y } // t=1.5 — offsetX only
|
|
269
|
+
const EMPTY = { x: 700, y: OVERLAY_Y }
|
|
270
|
+
/** Inside the ruler strip — the only place a scrub starts now. */
|
|
271
|
+
const RULER_Y = Math.round(LAYOUT.ruler.y + LAYOUT.ruler.height / 2)
|
|
272
|
+
|
|
273
|
+
// `sameKindProject`'s own layout: track 1 being VIDEO-kind (not overlay)
|
|
274
|
+
// makes it the TALL row too (any video track is, not just track 0), which
|
|
275
|
+
// shifts track 0's Y down from `BASE_Y` above — so the cross-track-move
|
|
276
|
+
// tests that use `sameKindProject` need their own Y's, not the ones derived
|
|
277
|
+
// from `baseProject`. X's are unaffected (purely time-based) and are reused
|
|
278
|
+
// from `C1_BODY`/`C0_BODY` above.
|
|
279
|
+
const SAME_KIND_LAYOUT = computeTimelineLayout(sameKindProject())
|
|
280
|
+
const sameKindRowMidY = (trackIdx: number) => {
|
|
281
|
+
const row = SAME_KIND_LAYOUT.rows.find(r => r.trackIdx === trackIdx)!
|
|
282
|
+
return Math.round(row.y + row.height / 2)
|
|
283
|
+
}
|
|
284
|
+
const SAME_KIND_BASE_Y = sameKindRowMidY(0)
|
|
285
|
+
const SAME_KIND_TRACK1_Y = sameKindRowMidY(1)
|
|
286
|
+
|
|
287
|
+
// ── Building blocks ──────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
describe('resolveGesture — the four trim-op bindings', () => {
|
|
290
|
+
const ctx = makeContext()
|
|
291
|
+
const bodyHit = hitTest(C0_BODY, ctx.layout, VIEWPORT)
|
|
292
|
+
const edgeHit = hitTest(C0_OUT_EDGE, ctx.layout, VIEWPORT)
|
|
293
|
+
const audioBodyHit = hitTest(A0_BODY, ctx.layout, VIEWPORT)
|
|
294
|
+
const audioEdgeHit = hitTest(A0_OUT_EDGE, ctx.layout, VIEWPORT)
|
|
295
|
+
|
|
296
|
+
it('plain edge-drag trims', () => {
|
|
297
|
+
expect(resolveGesture(edgeHit, mods())).toBe('trim')
|
|
298
|
+
})
|
|
299
|
+
it('Alt + edge-drag rolls', () => {
|
|
300
|
+
expect(resolveGesture(edgeHit, mods({ alt: true }))).toBe('roll')
|
|
301
|
+
})
|
|
302
|
+
it('plain body-drag moves', () => {
|
|
303
|
+
expect(resolveGesture(bodyHit, mods())).toBe('move')
|
|
304
|
+
})
|
|
305
|
+
it('Alt + body-drag slips', () => {
|
|
306
|
+
expect(resolveGesture(bodyHit, mods({ alt: true }))).toBe('slip')
|
|
307
|
+
})
|
|
308
|
+
it('Cmd or Ctrl + body-drag slides', () => {
|
|
309
|
+
expect(resolveGesture(bodyHit, mods({ meta: true }))).toBe('slide')
|
|
310
|
+
expect(resolveGesture(bodyHit, mods({ ctrl: true }))).toBe('slide')
|
|
311
|
+
})
|
|
312
|
+
it('shift does not change what a body-drag is', () => {
|
|
313
|
+
expect(resolveGesture(bodyHit, mods({ shift: true }))).toBe('move')
|
|
314
|
+
})
|
|
315
|
+
it('audio has no roll/slip/slide, so modifiers fall through', () => {
|
|
316
|
+
expect(resolveGesture(audioBodyHit, mods({ alt: true }))).toBe('audio-move')
|
|
317
|
+
expect(resolveGesture(audioBodyHit, mods({ meta: true }))).toBe('audio-move')
|
|
318
|
+
expect(resolveGesture(audioEdgeHit, mods({ alt: true }))).toBe('audio-trim')
|
|
319
|
+
})
|
|
320
|
+
it('drags out a marquee on empty timeline', () => {
|
|
321
|
+
expect(resolveGesture(hitTest(EMPTY, ctx.layout, VIEWPORT), mods())).toBe('marquee')
|
|
322
|
+
expect(resolveGesture(hitTest({ x: 800, y: LANE_Y }, ctx.layout, VIEWPORT), mods())).toBe('marquee')
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('isAdditive — the DOM selection modifier test', () => {
|
|
327
|
+
it('is shift OR meta OR ctrl, and nothing else', () => {
|
|
328
|
+
expect(isAdditive(mods())).toBe(false)
|
|
329
|
+
expect(isAdditive(mods({ shift: true }))).toBe(true)
|
|
330
|
+
expect(isAdditive(mods({ meta: true }))).toBe(true)
|
|
331
|
+
expect(isAdditive(mods({ ctrl: true }))).toBe(true)
|
|
332
|
+
expect(isAdditive(mods({ alt: true }))).toBe(false)
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
describe('cursorForHit', () => {
|
|
337
|
+
const ctx = makeContext()
|
|
338
|
+
it('matches the DOM affordances', () => {
|
|
339
|
+
expect(cursorForHit(hitTest(C0_BODY, ctx.layout, VIEWPORT))).toBe('grab')
|
|
340
|
+
expect(cursorForHit(hitTest(C0_OUT_EDGE, ctx.layout, VIEWPORT))).toBe('ew-resize')
|
|
341
|
+
expect(cursorForHit(hitTest(A0_BODY, ctx.layout, VIEWPORT))).toBe('grab')
|
|
342
|
+
// Empty track area is the plain arrow, not a hand: there is nothing to
|
|
343
|
+
// click there, only a selection to drag out.
|
|
344
|
+
expect(cursorForHit(hitTest(EMPTY, ctx.layout, VIEWPORT))).toBe('default')
|
|
345
|
+
// The ruler advertises the horizontal drag that scrubbing is.
|
|
346
|
+
expect(cursorForHit(hitTest({ x: 400, y: RULER_Y }, ctx.layout, VIEWPORT))).toBe('ew-resize')
|
|
347
|
+
// A fade grip is a resize too, but DIAGONAL (it sits at a top corner), so
|
|
348
|
+
// you can tell it apart from an edge trim's horizontal ew-resize: the
|
|
349
|
+
// fade-in grip (top-left) is nwse, the fade-out grip (top-right) is nesw.
|
|
350
|
+
expect(cursorForHit(hitTest(A0_FADE_IN_GRIP, ctx.layout, VIEWPORT))).toBe('nwse-resize')
|
|
351
|
+
expect(cursorForHit(hitTest(A0_FADE_OUT_GRIP, ctx.layout, VIEWPORT))).toBe('nesw-resize')
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
// ── Hover ────────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
describe('hover', () => {
|
|
358
|
+
it('emits a cursor only when it changes', () => {
|
|
359
|
+
const d = new Driver(makeContext())
|
|
360
|
+
expect(of(d.move(C0_BODY.x, C0_BODY.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'grab' }])
|
|
361
|
+
expect(d.move(C0_BODY.x + 20, C0_BODY.y)).toEqual([])
|
|
362
|
+
expect(of(d.move(C0_OUT_EDGE.x, C0_OUT_EDGE.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'ew-resize' }])
|
|
363
|
+
expect(of(d.move(EMPTY.x, EMPTY.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'default' }])
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('never edits anything', () => {
|
|
367
|
+
const d = new Driver(makeContext())
|
|
368
|
+
d.move(C0_BODY.x, C0_BODY.y)
|
|
369
|
+
expect(of(d.effects, 'projectChange')).toEqual([])
|
|
370
|
+
expect(of(d.effects, 'select')).toEqual([])
|
|
371
|
+
expect(d.machine.state.kind).toBe('idle')
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// ── Click-to-seek and scrubbing ──────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
describe('scrub on the ruler', () => {
|
|
378
|
+
it('seeks on press and starts scrubbing', () => {
|
|
379
|
+
const d = new Driver(makeContext())
|
|
380
|
+
const effects = d.down(EMPTY.x, RULER_Y)
|
|
381
|
+
expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 7 }])
|
|
382
|
+
expect(of(effects, 'cursor')).toEqual([{ type: 'cursor', cursor: 'ew-resize' }])
|
|
383
|
+
expect(d.machine.state.kind).toBe('dragging')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('clears the selection, so scrubbing drops what was selected', () => {
|
|
387
|
+
// Otherwise a clip stayed selected while you scrubbed somewhere else, and
|
|
388
|
+
// the next split or ripple-delete hit an item nowhere near the playhead.
|
|
389
|
+
const d = new Driver(makeContext())
|
|
390
|
+
expect(of(d.down(EMPTY.x, RULER_Y), 'select')).toEqual([{ type: 'select', id: null, additive: false }])
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('leaves the selection alone on an ADDITIVE press — shift builds a selection', () => {
|
|
394
|
+
const d = new Driver(makeContext())
|
|
395
|
+
expect(of(d.down(EMPTY.x, RULER_Y, mods({ shift: true })), 'select')).toEqual([])
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('magnetizes to a nearby boundary', () => {
|
|
399
|
+
const d = new Driver(makeContext())
|
|
400
|
+
// 5.1s is 10px from the c0/c1 cut — inside the attract radius.
|
|
401
|
+
expect(of(d.down(510, RULER_Y), 'seek')).toEqual([{ type: 'seek', time: 5 }])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('keeps seeking as the pointer drags, with hysteresis', () => {
|
|
405
|
+
const d = new Driver(makeContext())
|
|
406
|
+
d.down(505, RULER_Y)
|
|
407
|
+
expect(of(d.effects, 'seek')[0].time).toBe(5)
|
|
408
|
+
// 5.25s: past attract but well inside release, so it stays stuck on the cut.
|
|
409
|
+
expect(of(d.move(525, RULER_Y), 'seek')).toEqual([{ type: 'seek', time: 5 }])
|
|
410
|
+
// 5.6s clears release and the playhead comes free.
|
|
411
|
+
expect(of(d.move(560, RULER_Y), 'seek')[0].time).toBeCloseTo(5.6)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('clamps the playhead to the timeline', () => {
|
|
415
|
+
const d = new Driver(makeContext())
|
|
416
|
+
d.down(EMPTY.x, RULER_Y)
|
|
417
|
+
expect(of(d.move(-500, RULER_Y), 'seek')).toEqual([{ type: 'seek', time: 0 }])
|
|
418
|
+
expect(of(d.move(100000, RULER_Y), 'seek')[0].time).toBe(makeContext().totalDuration)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('commits nothing when the scrub ends', () => {
|
|
422
|
+
const d = new Driver(makeContext())
|
|
423
|
+
d.down(EMPTY.x, RULER_Y)
|
|
424
|
+
d.move(EMPTY.x + 50, RULER_Y)
|
|
425
|
+
expect(of(d.up(EMPTY.x + 50, RULER_Y), 'commit')).toEqual([])
|
|
426
|
+
expect(d.machine.state.kind).toBe('idle')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('keeps scrubbing when the pointer drifts above the strip', () => {
|
|
430
|
+
// hitTest claims everything at or above the ruler's bottom edge, so a hand
|
|
431
|
+
// that rises off the surface mid-drag does not drop the gesture.
|
|
432
|
+
const d = new Driver(makeContext())
|
|
433
|
+
d.down(400, RULER_Y)
|
|
434
|
+
expect(of(d.move(600, -30), 'seek')[0].time).toBeCloseTo(6)
|
|
435
|
+
})
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
describe('empty track area — click seeks, drag selects', () => {
|
|
439
|
+
it('does NOT seek on press: the same press may become a marquee', () => {
|
|
440
|
+
const d = new Driver(makeContext())
|
|
441
|
+
const effects = d.down(EMPTY.x, EMPTY.y)
|
|
442
|
+
expect(of(effects, 'seek')).toEqual([])
|
|
443
|
+
expect(of(effects, 'select')).toEqual([])
|
|
444
|
+
expect(d.machine.state.kind).toBe('pressed')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('seeks and clears the selection on a click that never moved', () => {
|
|
448
|
+
const d = new Driver(makeContext())
|
|
449
|
+
d.down(EMPTY.x, EMPTY.y)
|
|
450
|
+
const effects = d.up(EMPTY.x, EMPTY.y)
|
|
451
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: null, additive: false }])
|
|
452
|
+
expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 7 }])
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('leaves the selection alone on an additive click', () => {
|
|
456
|
+
const d = new Driver(makeContext())
|
|
457
|
+
d.down(EMPTY.x, EMPTY.y, mods({ shift: true }))
|
|
458
|
+
expect(of(d.up(EMPTY.x, EMPTY.y, mods({ shift: true })), 'select')).toEqual([])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('draws a marquee once the press crosses the drag threshold', () => {
|
|
462
|
+
const d = new Driver(makeContext())
|
|
463
|
+
d.down(EMPTY.x, EMPTY.y)
|
|
464
|
+
const effects = d.move(EMPTY.x + 60, EMPTY.y + 40)
|
|
465
|
+
expect(of(effects, 'marquee')).toEqual([
|
|
466
|
+
{ type: 'marquee', rect: { x: EMPTY.x, y: EMPTY.y, width: 60, height: 40 } },
|
|
467
|
+
])
|
|
468
|
+
expect(of(effects, 'cursor')).toEqual([{ type: 'cursor', cursor: 'crosshair' }])
|
|
469
|
+
// A marquee is pure selection: it must never touch the project.
|
|
470
|
+
expect(of(effects, 'projectChange')).toEqual([])
|
|
471
|
+
expect(of(effects, 'seek')).toEqual([])
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('normalizes a box dragged up and to the left', () => {
|
|
475
|
+
const d = new Driver(makeContext())
|
|
476
|
+
d.down(EMPTY.x, EMPTY.y)
|
|
477
|
+
const rect = of(d.move(EMPTY.x - 60, EMPTY.y - 10), 'marquee')[0].rect
|
|
478
|
+
expect(rect).toEqual({ x: EMPTY.x - 60, y: EMPTY.y - 10, width: 60, height: 10 })
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('selects everything the box touched, and takes the box down, on release', () => {
|
|
482
|
+
const d = new Driver(makeContext())
|
|
483
|
+
// From empty overlay-row space at x=700, back across c1 (5s-10s) on the
|
|
484
|
+
// base row below — a box spanning both rows.
|
|
485
|
+
d.down(EMPTY.x, OVERLAY_Y)
|
|
486
|
+
d.move(600, BASE_Y)
|
|
487
|
+
const effects = d.up(600, BASE_Y)
|
|
488
|
+
expect(of(effects, 'marquee')).toEqual([{ type: 'marquee', rect: null }])
|
|
489
|
+
const selected = of(effects, 'selectMany')[0]
|
|
490
|
+
expect(selected.ids).toContain('c1')
|
|
491
|
+
expect(selected.additive).toBe(false)
|
|
492
|
+
expect(of(effects, 'commit')).toEqual([])
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('extends the selection when the marquee is additive', () => {
|
|
496
|
+
const d = new Driver(makeContext())
|
|
497
|
+
d.down(EMPTY.x, OVERLAY_Y, mods({ shift: true }))
|
|
498
|
+
d.move(600, BASE_Y, mods({ shift: true }))
|
|
499
|
+
expect(of(d.up(600, BASE_Y, mods({ shift: true })), 'selectMany')[0].additive).toBe(true)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('a cancelled marquee takes its box down and selects nothing', () => {
|
|
503
|
+
const d = new Driver(makeContext())
|
|
504
|
+
d.down(EMPTY.x, EMPTY.y)
|
|
505
|
+
d.move(EMPTY.x + 60, EMPTY.y + 40)
|
|
506
|
+
const effects = d.cancel()
|
|
507
|
+
expect(of(effects, 'marquee')).toEqual([{ type: 'marquee', rect: null }])
|
|
508
|
+
expect(of(effects, 'selectMany')).toEqual([])
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('marquees from an empty audio lane too', () => {
|
|
512
|
+
const d = new Driver(makeContext())
|
|
513
|
+
d.down(800, LANE_Y)
|
|
514
|
+
expect(of(d.move(860, LANE_Y), 'marquee')).toHaveLength(1)
|
|
515
|
+
})
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
// ── Selection ────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
describe('selection', () => {
|
|
521
|
+
it('selects on release, and seeks on a plain click of an unselected clip', () => {
|
|
522
|
+
const d = new Driver(makeContext())
|
|
523
|
+
expect(d.down(C0_BODY.x, C0_BODY.y)).toEqual([{ type: 'cursor', cursor: 'grab' }])
|
|
524
|
+
const effects = d.up(C0_BODY.x, C0_BODY.y)
|
|
525
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'c0', additive: false }])
|
|
526
|
+
expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 2.5 }])
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('does not seek when the clip was already selected', () => {
|
|
530
|
+
const d = new Driver(makeContext({ selectedIds: ['c0'] }))
|
|
531
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
532
|
+
const effects = d.up(C0_BODY.x, C0_BODY.y)
|
|
533
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'c0', additive: false }])
|
|
534
|
+
expect(of(effects, 'seek')).toEqual([])
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('does not seek on an additive click', () => {
|
|
538
|
+
for (const modifier of ['shift', 'meta', 'ctrl'] as const) {
|
|
539
|
+
const d = new Driver(makeContext())
|
|
540
|
+
d.down(C0_BODY.x, C0_BODY.y, mods({ [modifier]: true }))
|
|
541
|
+
const effects = d.up(C0_BODY.x, C0_BODY.y, mods({ [modifier]: true }))
|
|
542
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'c0', additive: true }])
|
|
543
|
+
expect(of(effects, 'seek')).toEqual([])
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('reads the modifier at release, as the DOM click handler does', () => {
|
|
548
|
+
const d = new Driver(makeContext())
|
|
549
|
+
d.down(C0_BODY.x, C0_BODY.y, mods())
|
|
550
|
+
expect(of(d.up(C0_BODY.x, C0_BODY.y, mods({ shift: true })), 'select'))
|
|
551
|
+
.toEqual([{ type: 'select', id: 'c0', additive: true }])
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('selects an audio bar without seeking', () => {
|
|
555
|
+
const d = new Driver(makeContext())
|
|
556
|
+
d.down(A0_BODY.x, A0_BODY.y)
|
|
557
|
+
const effects = d.up(A0_BODY.x, A0_BODY.y)
|
|
558
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'a0', additive: false }])
|
|
559
|
+
expect(of(effects, 'seek')).toEqual([])
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('selects from a press on a trim handle that never became a drag', () => {
|
|
563
|
+
const d = new Driver(makeContext())
|
|
564
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
565
|
+
const effects = d.up(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
566
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'c0', additive: false }])
|
|
567
|
+
// …and does NOT persist a project that never changed.
|
|
568
|
+
expect(of(effects, 'commit')).toEqual([])
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('does not select when the press turned into a drag', () => {
|
|
572
|
+
const d = new Driver(makeContext())
|
|
573
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
574
|
+
d.move(C0_BODY.x + 60, C0_BODY.y)
|
|
575
|
+
expect(of(d.up(C0_BODY.x + 60, C0_BODY.y), 'select')).toEqual([])
|
|
576
|
+
})
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
// ── Drag threshold ───────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
describe('drag threshold', () => {
|
|
582
|
+
it('ignores travel under 4px and still counts as a click', () => {
|
|
583
|
+
const d = new Driver(makeContext())
|
|
584
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
585
|
+
expect(d.move(C0_BODY.x + 2, C0_BODY.y + 1)).toEqual([]) // hypot ≈ 2.2
|
|
586
|
+
expect(d.move(C0_BODY.x + 3, C0_BODY.y)).toEqual([])
|
|
587
|
+
expect(d.machine.state.kind).toBe('pressed')
|
|
588
|
+
expect(of(d.up(C0_BODY.x + 3, C0_BODY.y), 'select')).toHaveLength(1)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('becomes a drag at 4px, counting diagonal travel', () => {
|
|
592
|
+
const d = new Driver(makeContext())
|
|
593
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
594
|
+
expect(of(d.move(C0_BODY.x + 4, C0_BODY.y), 'projectChange')).toHaveLength(1)
|
|
595
|
+
expect(d.machine.state.kind).toBe('dragging')
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('applies to vertical travel too', () => {
|
|
599
|
+
const d = new Driver(makeContext())
|
|
600
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
601
|
+
expect(d.move(C0_BODY.x, C0_BODY.y + 3)).toEqual([])
|
|
602
|
+
expect(of(d.move(C0_BODY.x, C0_BODY.y + 5), 'projectChange')).toHaveLength(1)
|
|
603
|
+
})
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
// ── Item body drag (move) ────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
describe('body drag — move', () => {
|
|
609
|
+
it('moves the clip and snaps its leading edge', () => {
|
|
610
|
+
const d = new Driver(makeContext())
|
|
611
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
612
|
+
const moved = visual(lastProjectChange(d.move(C0_BODY.x + 100, C0_BODY.y)), 'c0')
|
|
613
|
+
expect(moved.start).toBeCloseTo(1)
|
|
614
|
+
expect(moved.end).toBeCloseTo(6)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('is not held back by the position it started from', () => {
|
|
618
|
+
// c0's own 0s/5s boundaries are excluded from its magnets, so a 20px nudge
|
|
619
|
+
// moves it rather than sticking it to the origin.
|
|
620
|
+
const d = new Driver(makeContext())
|
|
621
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
622
|
+
expect(visual(lastProjectChange(d.move(C0_BODY.x + 20, C0_BODY.y)), 'c0').start).toBeCloseTo(0.2)
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('clamps to the timeline at both ends', () => {
|
|
626
|
+
const ctx = makeContext()
|
|
627
|
+
const d = new Driver(ctx)
|
|
628
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
629
|
+
expect(visual(lastProjectChange(d.move(-9999, C0_BODY.y)), 'c0').start).toBe(0)
|
|
630
|
+
const far = visual(lastProjectChange(d.move(99999, C0_BODY.y)), 'c0')
|
|
631
|
+
expect(far.end).toBeCloseTo(ctx.totalDuration)
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('emits one projectChange per move and one commit at release', () => {
|
|
635
|
+
const d = new Driver(makeContext())
|
|
636
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
637
|
+
expect(of(d.move(C0_BODY.x + 50, C0_BODY.y), 'projectChange')).toHaveLength(1)
|
|
638
|
+
expect(of(d.move(C0_BODY.x + 60, C0_BODY.y), 'projectChange')).toHaveLength(1)
|
|
639
|
+
expect(of(d.move(C0_BODY.x + 70, C0_BODY.y), 'projectChange')).toHaveLength(1)
|
|
640
|
+
const committed = of(d.up(C0_BODY.x + 70, C0_BODY.y), 'commit')
|
|
641
|
+
expect(committed).toHaveLength(1)
|
|
642
|
+
expect(visual(committed[0].project, 'c0').start).toBeCloseTo(0.7)
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('accumulates across moves so the cross-track search sees its own work', () => {
|
|
646
|
+
// `sameKindProject`: track 1 must be video-kind (not overlay) for this
|
|
647
|
+
// move to land at all — see the fixture's own doc comment.
|
|
648
|
+
const d = new Driver(makeContext({ project: sameKindProject() }))
|
|
649
|
+
d.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
650
|
+
d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX) // up one track
|
|
651
|
+
const after = lastProjectChange(d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
|
|
652
|
+
expect(trackIndexOf(after, 'c1')).toBe(1)
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
it('switches the cursor to grabbing', () => {
|
|
656
|
+
const d = new Driver(makeContext())
|
|
657
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
658
|
+
expect(of(d.move(C0_BODY.x + 50, C0_BODY.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'grabbing' }])
|
|
659
|
+
})
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
describe('a viewport that pans mid-gesture — edge auto-scroll tracking', () => {
|
|
663
|
+
// Edge auto-scroll pans the view while the pointer is HELD in the edge band,
|
|
664
|
+
// re-feeding the machine that same screen point on every frame (see
|
|
665
|
+
// `edgeAutoScrollTick` in TimelineCanvas). Nothing about the pointer moves —
|
|
666
|
+
// only the viewport under it does — so a gesture that measured its travel in
|
|
667
|
+
// raw pixels would report no movement at all for those frames and leave what
|
|
668
|
+
// it is dragging parked at one time while the timeline slid out from under
|
|
669
|
+
// the cursor. Travel is measured in timeline time for exactly this reason.
|
|
670
|
+
const PANNED: Viewport = { ...VIEWPORT, scrollSeconds: 2 }
|
|
671
|
+
|
|
672
|
+
it('carries a moving clip along with the pan, keeping it under the cursor', () => {
|
|
673
|
+
const d = new Driver(makeContext({ snapConfig: ZERO_SNAP }))
|
|
674
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
675
|
+
const held = { x: C0_BODY.x + 100, y: C0_BODY.y }
|
|
676
|
+
expect(visual(lastProjectChange(d.move(held.x, held.y)), 'c0').start).toBeCloseTo(1)
|
|
677
|
+
|
|
678
|
+
// The view pans 2s to the right. The pointer does not move.
|
|
679
|
+
d.ctx = makeContext({ snapConfig: ZERO_SNAP, viewport: PANNED })
|
|
680
|
+
const panned = visual(lastProjectChange(d.move(held.x, held.y)), 'c0')
|
|
681
|
+
expect(panned.start).toBeCloseTo(3)
|
|
682
|
+
expect(panned.end).toBeCloseTo(8)
|
|
683
|
+
|
|
684
|
+
// The invariant that actually matters, stated directly: the cursor still
|
|
685
|
+
// sits the same 2.5s into the clip it grabbed at press, so the clip is
|
|
686
|
+
// still under the hand holding it.
|
|
687
|
+
expect(xToTime(held.x, PANNED) - panned.start).toBeCloseTo(2.5)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('carries a trimmed edge along with the pan too', () => {
|
|
691
|
+
// Not just moves: every horizontal gesture shares the delta, so a trim held
|
|
692
|
+
// in the edge band tracks the same way.
|
|
693
|
+
const d = new Driver(makeContext({ snapConfig: ZERO_SNAP }))
|
|
694
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
695
|
+
const held = { x: C0_OUT_EDGE.x - 100, y: C0_OUT_EDGE.y }
|
|
696
|
+
expect(visual(lastProjectChange(d.move(held.x, held.y)), 'c0').end).toBeCloseTo(4)
|
|
697
|
+
|
|
698
|
+
d.ctx = makeContext({ snapConfig: ZERO_SNAP, viewport: PANNED })
|
|
699
|
+
expect(visual(lastProjectChange(d.move(held.x, held.y)), 'c0').end).toBeCloseTo(6)
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('leaves a gesture alone when the viewport holds still', () => {
|
|
703
|
+
// The guard on the change above: anchoring in time must not alter the
|
|
704
|
+
// ordinary case, where scroll never moves and a pixel delta and a time
|
|
705
|
+
// delta agree exactly.
|
|
706
|
+
const d = new Driver(makeContext({ snapConfig: ZERO_SNAP }))
|
|
707
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
708
|
+
expect(visual(lastProjectChange(d.move(C0_BODY.x + 250, C0_BODY.y)), 'c0').start).toBeCloseTo(2.5)
|
|
709
|
+
})
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
describe('cross-track move', () => {
|
|
713
|
+
it('moves a clip up a track when the vertical travel points there', () => {
|
|
714
|
+
// `sameKindProject`: track 1 is video-kind here so the kind-lock doesn't
|
|
715
|
+
// block the very move this test is about — see its own doc comment.
|
|
716
|
+
const d = new Driver(makeContext({ project: sameKindProject() }))
|
|
717
|
+
d.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
718
|
+
// 24px of upward travel is one track; c1 (5s–10s) doesn't collide with the
|
|
719
|
+
// video o0 (2s–4s), so it lands on track 1.
|
|
720
|
+
const after = lastProjectChange(d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
|
|
721
|
+
expect(trackIndexOf(after, 'c1')).toBe(1)
|
|
722
|
+
expect(visual(after, 'c1').start).toBeCloseTo(5)
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it('stays where it is when the target track is occupied', () => {
|
|
726
|
+
const d = new Driver(makeContext())
|
|
727
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
728
|
+
// c0 (0s–5s) would overlap o0 (2s–4s) by 2s, past the 30%-of-duration
|
|
729
|
+
// tolerance, so the outward search falls back to its own track.
|
|
730
|
+
const after = lastProjectChange(d.move(C0_BODY.x, C0_BODY.y - VISUAL_ROW_HEIGHT_PX))
|
|
731
|
+
expect(trackIndexOf(after, 'c0')).toBe(0)
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
it('tolerates a brush past a neighbour', () => {
|
|
735
|
+
// A 5s clip may overlap by up to 1.5s. Drag c1 up while shifting it left so
|
|
736
|
+
// it overlaps o0 (2s–4s) by only 1s. `sameKindProject`: track 1 is
|
|
737
|
+
// video-kind so the kind-lock doesn't block the move before the overlap
|
|
738
|
+
// tolerance this test is actually about ever gets exercised.
|
|
739
|
+
const d = new Driver(makeContext({ project: sameKindProject() }))
|
|
740
|
+
d.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
741
|
+
const after = lastProjectChange(d.move(C1_BODY.x - 200, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
|
|
742
|
+
expect(visual(after, 'c1').start).toBeCloseTo(3)
|
|
743
|
+
expect(trackIndexOf(after, 'c1')).toBe(1)
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it('mints a new video track at the top of the video block, below any overlay track (Part B track grouping)', () => {
|
|
747
|
+
// Two steps up from track 0 mints a new track past the top of the
|
|
748
|
+
// existing (2-track) stack — but the result is RE-GROUPED: the new
|
|
749
|
+
// track is video-kind, so it joins the video block (alongside the
|
|
750
|
+
// remaining c1) ahead of the overlay track o0, rather than staying
|
|
751
|
+
// "literally on top" at index 2 the way an un-grouped mint would.
|
|
752
|
+
const d = new Driver(makeContext())
|
|
753
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
754
|
+
const after = lastProjectChange(d.move(C0_BODY.x, C0_BODY.y - VISUAL_ROW_HEIGHT_PX * 2))
|
|
755
|
+
expect(after.tracks).toHaveLength(3)
|
|
756
|
+
expect(trackIndexOf(after, 'c0')).toBe(1)
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
it('prunes a track the move emptied', () => {
|
|
760
|
+
// `sameKindProject`: o0 must be video-kind here — track 0 (its
|
|
761
|
+
// destination) is video-only, so an overlay `o0` could never land there
|
|
762
|
+
// and this test's own mechanic (the emptied track gets pruned) would
|
|
763
|
+
// never get to run.
|
|
764
|
+
const d = new Driver(makeContext({ project: sameKindProject() }))
|
|
765
|
+
d.down(300, SAME_KIND_TRACK1_Y) // o0, the only item on track 1
|
|
766
|
+
// Down two tracks and out past the end of the base track's content, where
|
|
767
|
+
// nothing collides — so track 1 is left empty and disappears.
|
|
768
|
+
const after = lastProjectChange(d.move(300 + 900, SAME_KIND_TRACK1_Y + 48))
|
|
769
|
+
expect(visual(after, 'o0').start).toBeCloseTo(11)
|
|
770
|
+
expect(after.tracks).toHaveLength(1)
|
|
771
|
+
expect(trackIndexOf(after, 'o0')).toBe(0)
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
it('survives a cross-track drag on a legacy-shape project (T6 regression)', () => {
|
|
775
|
+
// Before the T6 fix, applyMove passed `lastProject.tracks` straight to
|
|
776
|
+
// moveItemAcrossTracks, which does `t.items.filter(...)` on every track —
|
|
777
|
+
// a crash on a bare array. The call site now normalizes defensively, so
|
|
778
|
+
// this must behave identically to the object-shape test above.
|
|
779
|
+
// `sameKindLegacyProject`: same video-kind track 1 as `sameKindProject`,
|
|
780
|
+
// just in the legacy array-of-arrays shape this test targets.
|
|
781
|
+
const d = new Driver(makeContext({ project: sameKindLegacyProject() }))
|
|
782
|
+
d.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
783
|
+
const after = lastProjectChange(d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
|
|
784
|
+
expect(trackIndexOf(after, 'c1')).toBe(1)
|
|
785
|
+
expect(visual(after, 'c1').start).toBeCloseTo(5)
|
|
786
|
+
})
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
describe('cross-track move — ripple mode (makeSpace)', () => {
|
|
790
|
+
// `sameKindProject`: track 1 is video-kind here so the kind-lock doesn't
|
|
791
|
+
// block the moves these tests are actually about — see its own doc
|
|
792
|
+
// comment. `ZERO_SNAP` makes the dropped position land exactly on the
|
|
793
|
+
// hand-computed number rather than wherever the magnet pulls it, since the
|
|
794
|
+
// point of these tests is the ripple push amount, not snap behaviour.
|
|
795
|
+
it('ripple-inserts into the target track instead of bumping to another one when the drop collides', () => {
|
|
796
|
+
const d = new Driver(makeContext({ project: sameKindProject(), rippleMode: true, snapConfig: ZERO_SNAP }))
|
|
797
|
+
d.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
798
|
+
// c1 (5s–10s) moves up one track and 4s left, landing at 1s–6s — a 2s
|
|
799
|
+
// overlap with o0 (2s–4s), past the 30%-of-5s tolerance.
|
|
800
|
+
const after = lastProjectChange(d.move(C1_BODY.x - 400, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
|
|
801
|
+
expect(trackIndexOf(after, 'c1')).toBe(1)
|
|
802
|
+
expect(visual(after, 'c1')).toMatchObject({ start: 1, end: 6 })
|
|
803
|
+
// o0 is pushed right by c1's own 5s duration rather than the drag
|
|
804
|
+
// bumping to a different track.
|
|
805
|
+
expect(visual(after, 'o0')).toMatchObject({ start: 7, end: 9 })
|
|
806
|
+
expect(after.tracks).toHaveLength(2)
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
it('bumps to a different track for the identical drag when magnet is off (contrast)', () => {
|
|
810
|
+
const d = new Driver(makeContext({ project: sameKindProject(), rippleMode: false, snapConfig: ZERO_SNAP }))
|
|
811
|
+
d.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
812
|
+
const after = lastProjectChange(d.move(C1_BODY.x - 400, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
|
|
813
|
+
// Track 1 collides (o0) and, at this dropped position, so does track 0
|
|
814
|
+
// (c0, 0s-5s, overlapped by 4s), so the outward search mints a fresh
|
|
815
|
+
// track above both rather than landing on either — and o0 is left
|
|
816
|
+
// completely untouched.
|
|
817
|
+
expect(trackIndexOf(after, 'c1')).toBe(2)
|
|
818
|
+
expect(after.tracks).toHaveLength(3)
|
|
819
|
+
expect(visual(after, 'o0')).toMatchObject({ start: 2, end: 4 })
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
it('drops into a fitting gap unchanged when magnet is on — nothing to push', () => {
|
|
823
|
+
const d = new Driver(makeContext({ project: sameKindProject(), rippleMode: true, snapConfig: ZERO_SNAP }))
|
|
824
|
+
d.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
825
|
+
// Purely vertical travel: c1 keeps its own 5s–10s window, which doesn't
|
|
826
|
+
// overlap o0 (2s–4s) at all.
|
|
827
|
+
const after = lastProjectChange(d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
|
|
828
|
+
expect(trackIndexOf(after, 'c1')).toBe(1)
|
|
829
|
+
expect(visual(after, 'c1')).toMatchObject({ start: 5, end: 10 })
|
|
830
|
+
expect(visual(after, 'o0')).toMatchObject({ start: 2, end: 4 })
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
it('keeps the kind-lock in ripple mode — a video clip never lands on the overlay track', () => {
|
|
834
|
+
// `baseProject`: track 1 is genuinely overlay-kind here (unlike
|
|
835
|
+
// `sameKindProject`), which is exactly what this test needs.
|
|
836
|
+
const d = new Driver(makeContext({ rippleMode: true, snapConfig: ZERO_SNAP }))
|
|
837
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
838
|
+
// One track up targets track 1 (the overlay track); even though makeSpace
|
|
839
|
+
// is on, the kind gate must still refuse it.
|
|
840
|
+
const after = lastProjectChange(d.move(C0_BODY.x, C0_BODY.y - VISUAL_ROW_HEIGHT_PX))
|
|
841
|
+
expect(trackIndexOf(after, 'c0')).toBe(0)
|
|
842
|
+
expect(visual(after, 'o0')).toMatchObject({ start: 2, end: 4 })
|
|
843
|
+
})
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
// ── Edge trims ───────────────────────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
describe('edge trim', () => {
|
|
849
|
+
it('shortens a clip and walks its outPoint with it', () => {
|
|
850
|
+
const d = new Driver(makeContext())
|
|
851
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
852
|
+
const trimmed = visual(lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y)), 'c0')
|
|
853
|
+
expect(trimmed.end).toBeCloseTo(4)
|
|
854
|
+
expect(trimmed.outPoint).toBeCloseTo(4)
|
|
855
|
+
expect(trimmed.start).toBe(0)
|
|
856
|
+
expect(trimmed.inPoint).toBe(0)
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
it('walks the inPoint on an in-edge trim', () => {
|
|
860
|
+
const d = new Driver(makeContext())
|
|
861
|
+
d.down(C1_IN_EDGE.x, C1_IN_EDGE.y)
|
|
862
|
+
const trimmed = visual(lastProjectChange(d.move(C1_IN_EDGE.x + 100, C1_IN_EDGE.y)), 'c1')
|
|
863
|
+
expect(trimmed.start).toBeCloseTo(6)
|
|
864
|
+
expect(trimmed.inPoint).toBeCloseTo(3)
|
|
865
|
+
expect(trimmed.end).toBe(10)
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
it('recomputes from the pressed-at project rather than compounding', () => {
|
|
869
|
+
const d = new Driver(makeContext())
|
|
870
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
871
|
+
d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y)
|
|
872
|
+
d.move(C0_OUT_EDGE.x - 200, C0_OUT_EDGE.y)
|
|
873
|
+
// Back to where it started: the clip is back to its original length, which
|
|
874
|
+
// could not happen if each move applied its delta to the previous result.
|
|
875
|
+
const back = visual(lastProjectChange(d.move(C0_OUT_EDGE.x, C0_OUT_EDGE.y)), 'c0')
|
|
876
|
+
expect(back.end).toBeCloseTo(5)
|
|
877
|
+
expect(back.outPoint).toBeCloseTo(5)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it('keeps a clip at least 0.1s long', () => {
|
|
881
|
+
const d = new Driver(makeContext())
|
|
882
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
883
|
+
expect(visual(lastProjectChange(d.move(-9999, C0_OUT_EDGE.y)), 'c0').end).toBeCloseTo(0.1)
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
it('shows the resize cursor while trimming', () => {
|
|
887
|
+
const d = new Driver(makeContext())
|
|
888
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
889
|
+
expect(of(d.move(C0_OUT_EDGE.x - 50, C0_OUT_EDGE.y), 'cursor')).toEqual([]) // already ew-resize
|
|
890
|
+
expect(d.machine.state.cursor).toBe('ew-resize')
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('commits once at release', () => {
|
|
894
|
+
const d = new Driver(makeContext())
|
|
895
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
896
|
+
d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y)
|
|
897
|
+
const committed = of(d.up(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y), 'commit')
|
|
898
|
+
expect(committed).toHaveLength(1)
|
|
899
|
+
expect(visual(committed[0].project, 'c0').end).toBeCloseTo(4)
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
it('propagates the delta across a multi-selection', () => {
|
|
903
|
+
const d = new Driver(makeContext({ selectedIds: ['c0', 'o0'] }))
|
|
904
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
905
|
+
const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y))
|
|
906
|
+
expect(visual(next, 'c0').end).toBeCloseTo(4)
|
|
907
|
+
expect(visual(next, 'o0').end).toBeCloseTo(3) // 4s − 1s
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
it('leaves unselected items alone when only one clip is selected', () => {
|
|
911
|
+
const d = new Driver(makeContext({ selectedIds: ['c0'] }))
|
|
912
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
913
|
+
const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y))
|
|
914
|
+
expect(visual(next, 'o0').end).toBe(4)
|
|
915
|
+
})
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
describe('edge trim — ripple mode', () => {
|
|
919
|
+
it('closes the gap a trim opens', () => {
|
|
920
|
+
const d = new Driver(makeContext({ rippleMode: true }))
|
|
921
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
922
|
+
const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y))
|
|
923
|
+
expect(visual(next, 'c0').end).toBeCloseTo(4)
|
|
924
|
+
expect(visual(next, 'c1').start).toBeCloseTo(4) // pulled back by 1s
|
|
925
|
+
expect(visual(next, 'c1').end).toBeCloseTo(9)
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it('leaves the gap open with ripple off', () => {
|
|
929
|
+
const d = new Driver(makeContext({ rippleMode: false }))
|
|
930
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
931
|
+
const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y))
|
|
932
|
+
expect(visual(next, 'c1').start).toBe(5)
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
it('does not ripple a body move', () => {
|
|
936
|
+
const d = new Driver(makeContext({ rippleMode: true }))
|
|
937
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
938
|
+
const next = lastProjectChange(d.move(C0_BODY.x + 100, C0_BODY.y))
|
|
939
|
+
expect(visual(next, 'c1').start).toBe(5)
|
|
940
|
+
})
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
// ── Alt / Cmd trim ops ───────────────────────────────────────────────────
|
|
944
|
+
|
|
945
|
+
describe('Alt + edge-drag — roll', () => {
|
|
946
|
+
it('moves the shared boundary, leaving both clips in place', () => {
|
|
947
|
+
const d = new Driver(makeContext())
|
|
948
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y, mods({ alt: true }))
|
|
949
|
+
const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y, mods({ alt: true })))
|
|
950
|
+
expect(visual(next, 'c0').end).toBeCloseTo(4)
|
|
951
|
+
expect(visual(next, 'c0').outPoint).toBeCloseTo(4)
|
|
952
|
+
expect(visual(next, 'c0').start).toBe(0)
|
|
953
|
+
expect(visual(next, 'c1').start).toBeCloseTo(4)
|
|
954
|
+
expect(visual(next, 'c1').inPoint).toBeCloseTo(1)
|
|
955
|
+
expect(visual(next, 'c1').end).toBe(10) // the pair's span is unchanged
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
it('rolls the same boundary from either side', () => {
|
|
959
|
+
const d = new Driver(makeContext())
|
|
960
|
+
d.down(C1_IN_EDGE.x, C1_IN_EDGE.y, mods({ alt: true }))
|
|
961
|
+
const next = lastProjectChange(d.move(C1_IN_EDGE.x - 100, C1_IN_EDGE.y, mods({ alt: true })))
|
|
962
|
+
expect(visual(next, 'c0').end).toBeCloseTo(4)
|
|
963
|
+
expect(visual(next, 'c1').start).toBeCloseTo(4)
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
it('does nothing when there is no adjacent neighbour', () => {
|
|
967
|
+
const d = new Driver(makeContext())
|
|
968
|
+
// c0's in edge has nothing before it.
|
|
969
|
+
d.down(5, 60, mods({ alt: true }))
|
|
970
|
+
expect(of(d.move(100, 60, mods({ alt: true })), 'projectChange')).toEqual([])
|
|
971
|
+
expect(of(d.up(100, 60, mods({ alt: true })), 'commit')).toEqual([])
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
it('reads the modifier at press, so releasing Alt mid-drag keeps rolling', () => {
|
|
975
|
+
const d = new Driver(makeContext())
|
|
976
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y, mods({ alt: true }))
|
|
977
|
+
const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y, mods()))
|
|
978
|
+
expect(visual(next, 'c1').start).toBeCloseTo(4)
|
|
979
|
+
})
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
describe('Alt + body-drag — slip', () => {
|
|
983
|
+
it('moves the source window while the clip stays put', () => {
|
|
984
|
+
const d = new Driver(makeContext())
|
|
985
|
+
d.down(C1_BODY.x, C1_BODY.y, mods({ alt: true }))
|
|
986
|
+
const slipped = visual(lastProjectChange(d.move(C1_BODY.x + 100, C1_BODY.y, mods({ alt: true }))), 'c1')
|
|
987
|
+
// Dragging right pulls the media right, bringing EARLIER frames into view.
|
|
988
|
+
expect(slipped.inPoint).toBeCloseTo(1)
|
|
989
|
+
expect(slipped.outPoint).toBeCloseTo(6)
|
|
990
|
+
expect(slipped.start).toBe(5)
|
|
991
|
+
expect(slipped.end).toBe(10)
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
it('slips the other way when dragged left', () => {
|
|
995
|
+
const d = new Driver(makeContext())
|
|
996
|
+
d.down(C1_BODY.x, C1_BODY.y, mods({ alt: true }))
|
|
997
|
+
const slipped = visual(lastProjectChange(d.move(C1_BODY.x - 100, C1_BODY.y, mods({ alt: true }))), 'c1')
|
|
998
|
+
expect(slipped.inPoint).toBeCloseTo(3)
|
|
999
|
+
expect(slipped.outPoint).toBeCloseTo(8)
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
it('clamps at the start of the source media', () => {
|
|
1003
|
+
const d = new Driver(makeContext())
|
|
1004
|
+
d.down(C1_BODY.x, C1_BODY.y, mods({ alt: true }))
|
|
1005
|
+
const slipped = visual(lastProjectChange(d.move(C1_BODY.x + 9999, C1_BODY.y, mods({ alt: true }))), 'c1')
|
|
1006
|
+
expect(slipped.inPoint).toBe(0)
|
|
1007
|
+
expect(slipped.outPoint).toBe(5)
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
it('emits nothing for a clip with no source window to slip', () => {
|
|
1011
|
+
const d = new Driver(makeContext())
|
|
1012
|
+
d.down(300, OVERLAY_Y, mods({ alt: true })) // o0, an overlay
|
|
1013
|
+
expect(of(d.move(400, OVERLAY_Y, mods({ alt: true })), 'projectChange')).toEqual([])
|
|
1014
|
+
})
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
describe('Cmd/Ctrl + body-drag — slide', () => {
|
|
1018
|
+
it('moves the clip and lets its neighbour absorb it', () => {
|
|
1019
|
+
const d = new Driver(makeContext())
|
|
1020
|
+
d.down(C1_BODY.x, C1_BODY.y, mods({ meta: true }))
|
|
1021
|
+
const next = lastProjectChange(d.move(C1_BODY.x + 100, C1_BODY.y, mods({ meta: true })))
|
|
1022
|
+
expect(visual(next, 'c1').start).toBeCloseTo(6)
|
|
1023
|
+
expect(visual(next, 'c1').end).toBeCloseTo(11)
|
|
1024
|
+
expect(visual(next, 'c1').inPoint).toBe(2) // the source window is untouched
|
|
1025
|
+
expect(visual(next, 'c0').end).toBeCloseTo(6) // the neighbour grew
|
|
1026
|
+
expect(visual(next, 'c0').outPoint).toBeCloseTo(6)
|
|
1027
|
+
expect(visual(next, 'c0').start).toBe(0)
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
it('works with ctrl as well as meta', () => {
|
|
1031
|
+
const d = new Driver(makeContext())
|
|
1032
|
+
d.down(C1_BODY.x, C1_BODY.y, mods({ ctrl: true }))
|
|
1033
|
+
expect(visual(lastProjectChange(d.move(C1_BODY.x + 100, C1_BODY.y, mods({ ctrl: true }))), 'c1').start)
|
|
1034
|
+
.toBeCloseTo(6)
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
it('clamps rather than crushing the neighbour below its minimum', () => {
|
|
1038
|
+
const d = new Driver(makeContext())
|
|
1039
|
+
d.down(C1_BODY.x, C1_BODY.y, mods({ meta: true }))
|
|
1040
|
+
const next = lastProjectChange(d.move(C1_BODY.x - 9999, C1_BODY.y, mods({ meta: true })))
|
|
1041
|
+
expect(visual(next, 'c0').end - visual(next, 'c0').start).toBeCloseTo(0.1)
|
|
1042
|
+
})
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
// ── Audio ────────────────────────────────────────────────────────────────
|
|
1046
|
+
|
|
1047
|
+
describe('audio bar drag', () => {
|
|
1048
|
+
it('repositions the bar', () => {
|
|
1049
|
+
const d = new Driver(makeContext())
|
|
1050
|
+
d.down(A0_BODY.x, A0_BODY.y)
|
|
1051
|
+
const moved = audio(lastProjectChange(d.move(A0_BODY.x + 100, A0_BODY.y)), 'a0')
|
|
1052
|
+
expect(moved.start).toBeCloseTo(2)
|
|
1053
|
+
expect(moved.end).toBeCloseTo(7)
|
|
1054
|
+
expect(moved.lane).toBe(0)
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
it('moves the bar down a lane on downward travel', () => {
|
|
1058
|
+
const d = new Driver(makeContext())
|
|
1059
|
+
d.down(A0_BODY.x, A0_BODY.y)
|
|
1060
|
+
expect(audio(lastProjectChange(d.move(A0_BODY.x, A0_BODY.y + 40)), 'a0').lane).toBe(1)
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
it('never goes above lane 0', () => {
|
|
1064
|
+
const d = new Driver(makeContext())
|
|
1065
|
+
d.down(A0_BODY.x, A0_BODY.y)
|
|
1066
|
+
expect(audio(lastProjectChange(d.move(A0_BODY.x, A0_BODY.y - 200)), 'a0').lane).toBe(0)
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
it('commits once at release', () => {
|
|
1070
|
+
const d = new Driver(makeContext())
|
|
1071
|
+
d.down(A0_BODY.x, A0_BODY.y)
|
|
1072
|
+
d.move(A0_BODY.x + 100, A0_BODY.y)
|
|
1073
|
+
expect(of(d.up(A0_BODY.x + 100, A0_BODY.y), 'commit')).toHaveLength(1)
|
|
1074
|
+
})
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
describe('audio bar trim', () => {
|
|
1078
|
+
it('extends the bar and its source window', () => {
|
|
1079
|
+
const d = new Driver(makeContext())
|
|
1080
|
+
d.down(A0_OUT_EDGE.x, A0_OUT_EDGE.y)
|
|
1081
|
+
const trimmed = audio(lastProjectChange(d.move(A0_OUT_EDGE.x + 100, A0_OUT_EDGE.y)), 'a0')
|
|
1082
|
+
expect(trimmed.end).toBeCloseTo(7)
|
|
1083
|
+
expect(trimmed.outPoint).toBeCloseTo(6)
|
|
1084
|
+
expect(trimmed.inPoint).toBe(0)
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
it('walks the inPoint on an in-edge trim', () => {
|
|
1088
|
+
const d = new Driver(makeContext())
|
|
1089
|
+
d.down(103, LANE_Y)
|
|
1090
|
+
const trimmed = audio(lastProjectChange(d.move(203, LANE_Y)), 'a0')
|
|
1091
|
+
expect(trimmed.start).toBeCloseTo(2)
|
|
1092
|
+
expect(trimmed.inPoint).toBeCloseTo(1)
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
it('never trims audio with ripple, even when ripple mode is on', () => {
|
|
1096
|
+
const d = new Driver(makeContext({ rippleMode: true }))
|
|
1097
|
+
d.down(A0_OUT_EDGE.x, A0_OUT_EDGE.y)
|
|
1098
|
+
const next = lastProjectChange(d.move(A0_OUT_EDGE.x - 100, A0_OUT_EDGE.y))
|
|
1099
|
+
expect(visual(next, 'c1').start).toBe(5)
|
|
1100
|
+
})
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
describe('audio fade drag', () => {
|
|
1104
|
+
// `baseProject`'s a0 with a fade already set on one side — used by the
|
|
1105
|
+
// clamp/remove tests below, which need a non-zero starting value to drag
|
|
1106
|
+
// away from. Same track id/span, so `FADE_GRIP_Y` and the shared `LAYOUT`
|
|
1107
|
+
// still apply: fade values don't move the lane geometry.
|
|
1108
|
+
function withFadeOut(fadeOut: number): Project {
|
|
1109
|
+
const p = baseProject()
|
|
1110
|
+
return { ...p, audio: { tracks: [{ ...p.audio!.tracks[0], fadeOut }] } }
|
|
1111
|
+
}
|
|
1112
|
+
function withFadeIn(fadeIn: number): Project {
|
|
1113
|
+
const p = baseProject()
|
|
1114
|
+
return { ...p, audio: { tracks: [{ ...p.audio!.tracks[0], fadeIn }] } }
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
it('sets fadeIn dragging the fade-in grip inward (right)', () => {
|
|
1118
|
+
const d = new Driver(makeContext())
|
|
1119
|
+
d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
|
|
1120
|
+
const faded = audio(lastProjectChange(d.move(A0_FADE_IN_GRIP.x + 100, A0_FADE_IN_GRIP.y)), 'a0')
|
|
1121
|
+
expect(faded.fadeIn).toBeCloseTo(1)
|
|
1122
|
+
expect(faded.fadeOut ?? 0).toBe(0)
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
it('sets fadeOut dragging the fade-out grip inward (left)', () => {
|
|
1126
|
+
const d = new Driver(makeContext())
|
|
1127
|
+
d.down(A0_FADE_OUT_GRIP.x, A0_FADE_OUT_GRIP.y)
|
|
1128
|
+
const faded = audio(lastProjectChange(d.move(A0_FADE_OUT_GRIP.x - 150, A0_FADE_OUT_GRIP.y)), 'a0')
|
|
1129
|
+
expect(faded.fadeOut).toBeCloseTo(1.5)
|
|
1130
|
+
expect(faded.fadeIn ?? 0).toBe(0)
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
it('clamps fadeIn at zero — dragging outward past the corner cannot go negative', () => {
|
|
1134
|
+
const d = new Driver(makeContext())
|
|
1135
|
+
d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
|
|
1136
|
+
const faded = audio(lastProjectChange(d.move(A0_FADE_IN_GRIP.x - 100, A0_FADE_IN_GRIP.y)), 'a0')
|
|
1137
|
+
expect(faded.fadeIn).toBe(0)
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
it('removes an existing fadeIn when dragged back past the corner', () => {
|
|
1141
|
+
// fadeIn:2 puts the grip at t=1+2=3 → x=300.
|
|
1142
|
+
const d = new Driver(makeContext({ project: withFadeIn(2) }))
|
|
1143
|
+
d.down(300, FADE_GRIP_Y)
|
|
1144
|
+
const faded = audio(lastProjectChange(d.move(300 - 500, FADE_GRIP_Y)), 'a0')
|
|
1145
|
+
expect(faded.fadeIn).toBe(0)
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
it('clamps so fadeIn + fadeOut never exceeds the bar\'s duration', () => {
|
|
1149
|
+
// duration is 5s (1s–6s); fadeOut is already 3s, so fadeIn can grow to at
|
|
1150
|
+
// most 2s no matter how far the grip is dragged.
|
|
1151
|
+
const d = new Driver(makeContext({ project: withFadeOut(3) }))
|
|
1152
|
+
d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
|
|
1153
|
+
const faded = audio(lastProjectChange(d.move(A0_FADE_IN_GRIP.x + 1000, A0_FADE_IN_GRIP.y)), 'a0')
|
|
1154
|
+
expect(faded.fadeIn).toBeCloseTo(2)
|
|
1155
|
+
expect(faded.fadeOut).toBe(3) // untouched — this drag only ever touches its own side
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
it('shows the diagonal fade-resize cursor while dragging a fade grip', () => {
|
|
1159
|
+
const d = new Driver(makeContext())
|
|
1160
|
+
d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
|
|
1161
|
+
// Already nwse-resize from the press-time hit (the fade-in grip's diagonal),
|
|
1162
|
+
// and the fade gesture holds that same cursor, so the move emits no change.
|
|
1163
|
+
expect(of(d.move(A0_FADE_IN_GRIP.x + 50, A0_FADE_IN_GRIP.y), 'cursor')).toEqual([])
|
|
1164
|
+
expect(d.machine.state.cursor).toBe('nwse-resize')
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
it('emits one projectChange per move and a single commit at release', () => {
|
|
1168
|
+
const d = new Driver(makeContext())
|
|
1169
|
+
d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
|
|
1170
|
+
expect(of(d.move(A0_FADE_IN_GRIP.x + 50, A0_FADE_IN_GRIP.y), 'projectChange')).toHaveLength(1)
|
|
1171
|
+
expect(of(d.move(A0_FADE_IN_GRIP.x + 80, A0_FADE_IN_GRIP.y), 'projectChange')).toHaveLength(1)
|
|
1172
|
+
const committed = of(d.up(A0_FADE_IN_GRIP.x + 80, A0_FADE_IN_GRIP.y), 'commit')
|
|
1173
|
+
expect(committed).toHaveLength(1)
|
|
1174
|
+
expect(audio(committed[0].project, 'a0').fadeIn).toBeCloseTo(0.8)
|
|
1175
|
+
})
|
|
1176
|
+
|
|
1177
|
+
it('never disturbs the audio-trim gesture on the same bar\'s edge', () => {
|
|
1178
|
+
// A0_OUT_EDGE sits in the bar's full-height edge zone, well below the
|
|
1179
|
+
// fade grip's small top zone — the trim gesture must fire, not a fade.
|
|
1180
|
+
const d = new Driver(makeContext())
|
|
1181
|
+
d.down(A0_OUT_EDGE.x, A0_OUT_EDGE.y)
|
|
1182
|
+
const trimmed = audio(lastProjectChange(d.move(A0_OUT_EDGE.x + 100, A0_OUT_EDGE.y)), 'a0')
|
|
1183
|
+
expect(trimmed.end).toBeCloseTo(7)
|
|
1184
|
+
expect(trimmed.fadeIn ?? 0).toBe(0)
|
|
1185
|
+
})
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
describe('keyframe drag (SP9b T3.3)', () => {
|
|
1189
|
+
function offsetXTrack(item: ReturnType<typeof visual>) {
|
|
1190
|
+
return item.keyframes!.find(t => t.prop === 'offsetX')!
|
|
1191
|
+
}
|
|
1192
|
+
function opacityTrack(item: ReturnType<typeof visual>) {
|
|
1193
|
+
return item.keyframes!.find(t => t.prop === 'opacity')!
|
|
1194
|
+
}
|
|
1195
|
+
/** Pixel-delta drags land on a float (30px / 100px-per-second = 0.3, not
|
|
1196
|
+
* exactly representable) — asserts the SET of times, each within floating
|
|
1197
|
+
* tolerance, rather than a brittle exact-array `toEqual`. */
|
|
1198
|
+
function expectTimes(track: { points: { t: number }[] }, expected: number[]) {
|
|
1199
|
+
const ts = track.points.map(p => p.t)
|
|
1200
|
+
expect(ts).toHaveLength(expected.length)
|
|
1201
|
+
ts.forEach((t, i) => expect(t).toBeCloseTo(expected[i]))
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
it('retimes every prop sharing the dragged diamond′s t together', () => {
|
|
1205
|
+
// ZERO_SNAP so the landing time is exact, hand-computed, not wherever a
|
|
1206
|
+
// magnet happens to pull it.
|
|
1207
|
+
const d = new Driver(makeContext({
|
|
1208
|
+
project: keyframedProject(),
|
|
1209
|
+
selectedIds: ['o0'],
|
|
1210
|
+
snapConfig: ZERO_SNAP,
|
|
1211
|
+
}))
|
|
1212
|
+
d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
|
|
1213
|
+
// +30px = +0.3s -> t 0.5 -> 0.8.
|
|
1214
|
+
const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)), 'o0')
|
|
1215
|
+
expectTimes(offsetXTrack(moved), [0.8, 1.5])
|
|
1216
|
+
expect(offsetXTrack(moved).points[0].value).toBe(0)
|
|
1217
|
+
expectTimes(opacityTrack(moved), [0.8])
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
it('does not touch a track that has no point at the dragged t', () => {
|
|
1221
|
+
const d = new Driver(makeContext({
|
|
1222
|
+
project: keyframedProject(),
|
|
1223
|
+
selectedIds: ['o0'],
|
|
1224
|
+
snapConfig: ZERO_SNAP,
|
|
1225
|
+
}))
|
|
1226
|
+
d.down(O0_KEYFRAME_SOLO.x, O0_KEYFRAME_SOLO.y)
|
|
1227
|
+
// +20px = +0.2s -> t 1.5 -> 1.7. opacity has no point at 1.5, so it must
|
|
1228
|
+
// stay exactly where it was.
|
|
1229
|
+
const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SOLO.x + 20, O0_KEYFRAME_SOLO.y)), 'o0')
|
|
1230
|
+
expectTimes(offsetXTrack(moved), [0.5, 1.7])
|
|
1231
|
+
expectTimes(opacityTrack(moved), [0.5])
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
it('clamps at the item′s own start (t=0), dragging far past it', () => {
|
|
1235
|
+
const d = new Driver(makeContext({
|
|
1236
|
+
project: keyframedProject(),
|
|
1237
|
+
selectedIds: ['o0'],
|
|
1238
|
+
snapConfig: ZERO_SNAP,
|
|
1239
|
+
}))
|
|
1240
|
+
d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
|
|
1241
|
+
const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SHARED.x - 1000, O0_KEYFRAME_SHARED.y)), 'o0')
|
|
1242
|
+
expect(offsetXTrack(moved).points.map(p => p.t)).toEqual([0, 1.5])
|
|
1243
|
+
expect(opacityTrack(moved).points.map(p => p.t)).toEqual([0])
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
it('clamps at the item′s own end (t=duration), dragging far past it', () => {
|
|
1247
|
+
const d = new Driver(makeContext({
|
|
1248
|
+
project: keyframedProject(),
|
|
1249
|
+
selectedIds: ['o0'],
|
|
1250
|
+
snapConfig: ZERO_SNAP,
|
|
1251
|
+
}))
|
|
1252
|
+
d.down(O0_KEYFRAME_SOLO.x, O0_KEYFRAME_SOLO.y)
|
|
1253
|
+
// o0 is 2s–4s, so item-relative duration is 2 — the drag cannot push the
|
|
1254
|
+
// diamond past t=2 however far the pointer travels.
|
|
1255
|
+
const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SOLO.x + 1000, O0_KEYFRAME_SOLO.y)), 'o0')
|
|
1256
|
+
expect(offsetXTrack(moved).points.map(p => p.t)).toEqual([0.5, 2])
|
|
1257
|
+
})
|
|
1258
|
+
|
|
1259
|
+
it('snaps a dragged diamond onto the item′s own end boundary', () => {
|
|
1260
|
+
// Default (non-zero) snap config: drag from t=1.5 towards t=2 (the item's
|
|
1261
|
+
// own duration/end), landing just short of it — within the 20px/0.2s
|
|
1262
|
+
// strong-attract radius (DEFAULT_SNAP_CONFIG), so it catches exactly on 2.
|
|
1263
|
+
const d = new Driver(makeContext({ project: keyframedProject(), selectedIds: ['o0'] }))
|
|
1264
|
+
d.down(O0_KEYFRAME_SOLO.x, O0_KEYFRAME_SOLO.y)
|
|
1265
|
+
const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SOLO.x + 45, O0_KEYFRAME_SOLO.y)), 'o0')
|
|
1266
|
+
expect(offsetXTrack(moved).points.some(p => p.t === 2)).toBe(true)
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
it('shows the ew-resize retime cursor while dragging a diamond', () => {
|
|
1270
|
+
const d = new Driver(makeContext({ project: keyframedProject(), selectedIds: ['o0'] }))
|
|
1271
|
+
d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
|
|
1272
|
+
// Already ew-resize from the press-time hit, and the gesture holds the
|
|
1273
|
+
// same cursor, so the move emits no cursor change.
|
|
1274
|
+
expect(of(d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y), 'cursor')).toEqual([])
|
|
1275
|
+
expect(d.machine.state.cursor).toBe('ew-resize')
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
it('emits one projectChange per move and a single commit at release', () => {
|
|
1279
|
+
const d = new Driver(makeContext({
|
|
1280
|
+
project: keyframedProject(),
|
|
1281
|
+
selectedIds: ['o0'],
|
|
1282
|
+
snapConfig: ZERO_SNAP,
|
|
1283
|
+
}))
|
|
1284
|
+
d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
|
|
1285
|
+
expect(of(d.move(O0_KEYFRAME_SHARED.x + 10, O0_KEYFRAME_SHARED.y), 'projectChange')).toHaveLength(1)
|
|
1286
|
+
expect(of(d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y), 'projectChange')).toHaveLength(1)
|
|
1287
|
+
const committed = of(d.up(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y), 'commit')
|
|
1288
|
+
expect(committed).toHaveLength(1)
|
|
1289
|
+
expectTimes(offsetXTrack(visual(committed[0].project, 'o0')), [0.8, 1.5])
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
it('never becomes a gesture at all when the overlay is not selected — the ordinary item move fires instead', () => {
|
|
1293
|
+
const d = new Driver(makeContext({ project: keyframedProject(), selectedIds: [] }))
|
|
1294
|
+
d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
|
|
1295
|
+
const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)), 'o0')
|
|
1296
|
+
// The whole item slid, not a keyframe — its keyframes are untouched and
|
|
1297
|
+
// its own start/end moved instead.
|
|
1298
|
+
expect(offsetXTrack(moved).points.map(p => p.t)).toEqual([0.5, 1.5])
|
|
1299
|
+
expect(moved.start).not.toBe(2)
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
describe('selection follows a retimed diamond (CapCut-correct)', () => {
|
|
1303
|
+
it('carries the selection from the old t to the new one, on the SAME commit that applies the retime', () => {
|
|
1304
|
+
// ctx.selectedKeyframe names the diamond BEFORE the drag (t=0.5) — the
|
|
1305
|
+
// one being dragged here. Without the fix `selectedKeyframe` would keep
|
|
1306
|
+
// pointing at t=0.5, which `moveKeyframe` has just vacated.
|
|
1307
|
+
const d = new Driver(makeContext({
|
|
1308
|
+
project: keyframedProject(),
|
|
1309
|
+
selectedIds: ['o0'],
|
|
1310
|
+
selectedKeyframe: { itemId: 'o0', t: 0.5 },
|
|
1311
|
+
snapConfig: ZERO_SNAP,
|
|
1312
|
+
}))
|
|
1313
|
+
d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
|
|
1314
|
+
d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y) // +30px = +0.3s -> t 0.5 -> 0.8
|
|
1315
|
+
const effects = d.up(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
|
|
1316
|
+
|
|
1317
|
+
expect(of(effects, 'commit')).toHaveLength(1)
|
|
1318
|
+
// Fires in the SAME batch of effects as the commit — one gesture, one
|
|
1319
|
+
// update — not deferred to a later, project-watching pass. Pixel-delta
|
|
1320
|
+
// drags land on a float (30px / 100px-per-second = 0.3, not exactly
|
|
1321
|
+
// representable) — same tolerance `expectTimes` above uses.
|
|
1322
|
+
const selects = of(effects, 'selectKeyframe')
|
|
1323
|
+
expect(selects).toHaveLength(1)
|
|
1324
|
+
expect(selects[0].itemId).toBe('o0')
|
|
1325
|
+
expect(selects[0].t).toBeCloseTo(0.8)
|
|
1326
|
+
})
|
|
1327
|
+
|
|
1328
|
+
it('leaves the selection alone when the DRAGGED diamond is not the selected one', () => {
|
|
1329
|
+
// o0's OTHER diamond (t=1.5, offsetX-only) is selected; the shared t=0.5
|
|
1330
|
+
// diamond is what gets dragged. Grabbing an unrelated diamond must not
|
|
1331
|
+
// steal the selection onto it.
|
|
1332
|
+
const d = new Driver(makeContext({
|
|
1333
|
+
project: keyframedProject(),
|
|
1334
|
+
selectedIds: ['o0'],
|
|
1335
|
+
selectedKeyframe: { itemId: 'o0', t: 1.5 },
|
|
1336
|
+
snapConfig: ZERO_SNAP,
|
|
1337
|
+
}))
|
|
1338
|
+
d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
|
|
1339
|
+
d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
|
|
1340
|
+
const effects = d.up(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
|
|
1341
|
+
|
|
1342
|
+
expect(of(effects, 'commit')).toHaveLength(1)
|
|
1343
|
+
expect(of(effects, 'selectKeyframe')).toEqual([])
|
|
1344
|
+
})
|
|
1345
|
+
|
|
1346
|
+
it('leaves the selection alone when nothing was selected at all', () => {
|
|
1347
|
+
const d = new Driver(makeContext({
|
|
1348
|
+
project: keyframedProject(),
|
|
1349
|
+
selectedIds: ['o0'],
|
|
1350
|
+
selectedKeyframe: null,
|
|
1351
|
+
snapConfig: ZERO_SNAP,
|
|
1352
|
+
}))
|
|
1353
|
+
d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
|
|
1354
|
+
d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
|
|
1355
|
+
const effects = d.up(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
|
|
1356
|
+
|
|
1357
|
+
expect(of(effects, 'selectKeyframe')).toEqual([])
|
|
1358
|
+
})
|
|
1359
|
+
})
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
describe('audio lane magnet', () => {
|
|
1363
|
+
// Own fixture: a lane with a real gap between two MAGNETIC clips, so a
|
|
1364
|
+
// commit has something to close. b0/b1 sit in lane 1, non-magnetic, purely
|
|
1365
|
+
// as the drag target for the cross-lane-inheritance test below.
|
|
1366
|
+
function magnetProject(): Project {
|
|
1367
|
+
return {
|
|
1368
|
+
id: 'p',
|
|
1369
|
+
tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', start: 0, end: 10 }] }],
|
|
1370
|
+
audio: { tracks: [
|
|
1371
|
+
{ id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0, magnetic: true },
|
|
1372
|
+
{ id: 'a1', src: 'm.mp3', start: 5, end: 8, lane: 0, magnetic: true },
|
|
1373
|
+
{ id: 'b0', src: 'n.mp3', start: 5, end: 7, lane: 1 },
|
|
1374
|
+
] },
|
|
1375
|
+
} as unknown as Project
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const project = magnetProject()
|
|
1379
|
+
const layout = computeTimelineLayout(project)
|
|
1380
|
+
const lane0Y = Math.round(layout.lanes[0].y + layout.lanes[0].height / 2)
|
|
1381
|
+
const lane1Y = Math.round(layout.lanes[1].y + layout.lanes[1].height / 2)
|
|
1382
|
+
|
|
1383
|
+
it('leaves the mid-drag (projectChange) frames alone — reflow is commit-only', () => {
|
|
1384
|
+
const d = new Driver(makeContext({ project: magnetProject() }))
|
|
1385
|
+
// a0 spans [0,2] → body inside that span.
|
|
1386
|
+
d.down(50, lane0Y)
|
|
1387
|
+
// +1.5s: still short of a1 (start 5), and far enough from every snap
|
|
1388
|
+
// candidate ({-2,0,3,5,6,8,10}, all >0.2s away) not to get caught.
|
|
1389
|
+
const mid = lastProjectChange(d.move(200, lane0Y))
|
|
1390
|
+
expect(audio(mid, 'a0').start).toBeCloseTo(1.5)
|
|
1391
|
+
expect(audio(mid, 'a1').start).toBe(5) // untouched mid-drag — a real gap still exists
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
it('snaps the magnetic lane gapless on release, leaving a non-magnetic lane alone', () => {
|
|
1395
|
+
const d = new Driver(makeContext({ project: magnetProject() }))
|
|
1396
|
+
d.down(50, lane0Y)
|
|
1397
|
+
d.move(200, lane0Y)
|
|
1398
|
+
const committed = of(d.up(200, lane0Y), 'commit')
|
|
1399
|
+
expect(committed).toHaveLength(1)
|
|
1400
|
+
const out = committed[0].project
|
|
1401
|
+
const finalA0 = audio(out, 'a0')
|
|
1402
|
+
const finalA1 = audio(out, 'a1')
|
|
1403
|
+
expect(finalA0.start).toBeCloseTo(1.5)
|
|
1404
|
+
expect(finalA0.end).toBeCloseTo(3.5)
|
|
1405
|
+
expect(finalA1.start).toBeCloseTo(finalA0.end) // gap closed, duration (3) preserved
|
|
1406
|
+
expect(finalA1.end).toBeCloseTo(6.5)
|
|
1407
|
+
// b0's lane (1) carries no magnetic clip — untouched by the same commit.
|
|
1408
|
+
expect(audio(out, 'b0')).toMatchObject({ start: 5, end: 7 })
|
|
1409
|
+
})
|
|
1410
|
+
|
|
1411
|
+
it('a clip dragged onto a magnetic lane adopts that lane\'s magnet state', () => {
|
|
1412
|
+
const d = new Driver(makeContext({ project: magnetProject() }))
|
|
1413
|
+
// b0 spans [5,7] in lane 1 → body inside that span.
|
|
1414
|
+
d.down(600, lane1Y)
|
|
1415
|
+
// Straight up one lane (dy = -AUDIO_LANE_HEIGHT_PX), no horizontal travel:
|
|
1416
|
+
// destLane = max(0, 1 + round(-40/40)) = 0, which holds only a0/a1 —
|
|
1417
|
+
// both magnetic — so b0 should adopt magnetic: true.
|
|
1418
|
+
const mid = lastProjectChange(d.move(600, lane1Y - 40))
|
|
1419
|
+
const movedB0 = audio(mid, 'b0')
|
|
1420
|
+
expect(movedB0.lane).toBe(0)
|
|
1421
|
+
expect(movedB0.magnetic).toBe(true)
|
|
1422
|
+
})
|
|
1423
|
+
|
|
1424
|
+
it('a clip dragged onto an EMPTY lane leaves magnetic untouched', () => {
|
|
1425
|
+
const d = new Driver(makeContext({ project: magnetProject() }))
|
|
1426
|
+
d.down(600, lane1Y)
|
|
1427
|
+
// Straight down one lane, into lane 2 — nothing lives there yet.
|
|
1428
|
+
const mid = lastProjectChange(d.move(600, lane1Y + 40))
|
|
1429
|
+
const movedB0 = audio(mid, 'b0')
|
|
1430
|
+
expect(movedB0.lane).toBe(2)
|
|
1431
|
+
expect(movedB0.magnetic).toBeUndefined()
|
|
1432
|
+
})
|
|
1433
|
+
})
|
|
1434
|
+
|
|
1435
|
+
// ── Inspection ───────────────────────────────────────────────────────────
|
|
1436
|
+
|
|
1437
|
+
describe('double-click', () => {
|
|
1438
|
+
// Double-clicking bare timeline used to place the A/B range markers. That
|
|
1439
|
+
// feature is gone: background double-clicks now emit nothing at all, and a
|
|
1440
|
+
// clip or audio bar still opens its inspector.
|
|
1441
|
+
it('does nothing on empty timeline', () => {
|
|
1442
|
+
const d = new Driver(makeContext())
|
|
1443
|
+
expect(d.doubleClick(700, 20)).toEqual([])
|
|
1444
|
+
})
|
|
1445
|
+
|
|
1446
|
+
it('does nothing on an empty audio lane', () => {
|
|
1447
|
+
const d = new Driver(makeContext())
|
|
1448
|
+
expect(d.doubleClick(800, LANE_Y)).toEqual([])
|
|
1449
|
+
})
|
|
1450
|
+
|
|
1451
|
+
it('does nothing past the end of the timeline', () => {
|
|
1452
|
+
const d = new Driver(makeContext())
|
|
1453
|
+
expect(d.doubleClick(-100, 20)).toEqual([])
|
|
1454
|
+
})
|
|
1455
|
+
|
|
1456
|
+
it('opens the clip inspector on a clip', () => {
|
|
1457
|
+
const d = new Driver(makeContext())
|
|
1458
|
+
const effects = d.doubleClick(C0_BODY.x, C0_BODY.y)
|
|
1459
|
+
expect(effects).toEqual([{ type: 'inspect', target: 'visual', id: 'c0' }])
|
|
1460
|
+
})
|
|
1461
|
+
|
|
1462
|
+
it('opens the audio inspector on a bar', () => {
|
|
1463
|
+
const d = new Driver(makeContext())
|
|
1464
|
+
expect(d.doubleClick(A0_BODY.x, A0_BODY.y)).toEqual([{ type: 'inspect', target: 'audio', id: 'a0' }])
|
|
1465
|
+
})
|
|
1466
|
+
|
|
1467
|
+
it('double-clicking a keyframe diamond selects it rather than opening an inspector', () => {
|
|
1468
|
+
// Was: emitted {type:'inspect'} identically to a clip-body double-click,
|
|
1469
|
+
// which goes nowhere now that the properties panel replaced that modal.
|
|
1470
|
+
// This branch had zero coverage before.
|
|
1471
|
+
const ctx = makeContext({ project: keyframedProject(), selectedIds: ['o0'] })
|
|
1472
|
+
const effects = new Driver(ctx).doubleClick(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
|
|
1473
|
+
|
|
1474
|
+
expect(of(effects, 'selectKeyframe')).toEqual([{ type: 'selectKeyframe', itemId: 'o0', t: 0.5 }])
|
|
1475
|
+
expect(of(effects, 'inspect')).toHaveLength(0)
|
|
1476
|
+
// Selecting a diamond is not an edit.
|
|
1477
|
+
expect(of(effects, 'projectChange')).toHaveLength(0)
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
it('reports the diamond\'s own item-relative t, not absolute timeline time', () => {
|
|
1481
|
+
// o0 starts at 2s, so the t=1.5 diamond sits at absolute 3.5s. The effect
|
|
1482
|
+
// must carry 1.5 — `Keyframe.t` is item-relative and every consumer
|
|
1483
|
+
// (removeKeyframesAt, moveKeyframe) assumes that.
|
|
1484
|
+
const ctx = makeContext({ project: keyframedProject(), selectedIds: ['o0'] })
|
|
1485
|
+
const effects = new Driver(ctx).doubleClick(O0_KEYFRAME_SOLO.x, O0_KEYFRAME_SOLO.y)
|
|
1486
|
+
|
|
1487
|
+
expect(of(effects, 'selectKeyframe')[0].t).toBe(1.5)
|
|
1488
|
+
})
|
|
1489
|
+
})
|
|
1490
|
+
|
|
1491
|
+
describe('double-click to key (selected element only)', () => {
|
|
1492
|
+
// WHICH props the gesture keys is `transformProps(item)`'s call, not a fixed
|
|
1493
|
+
// list — a uniform overlay gets `scale`, one authored per-axis gets
|
|
1494
|
+
// `scaleX`/`scaleY`. `keyframedProject`'s o0 carries neither per-axis field,
|
|
1495
|
+
// so it is uniform and every case below but the last two uses this list.
|
|
1496
|
+
const KEYFRAME_PROPS = ['offsetX', 'offsetY', 'scale', 'rotation', 'opacity'] as const
|
|
1497
|
+
const PER_AXIS_PROPS = ['offsetX', 'offsetY', 'scaleX', 'scaleY', 'rotation', 'opacity'] as const
|
|
1498
|
+
const O0_BODY = { x: 300, y: OVERLAY_Y } // absolute 3s -> localT 1 on o0 (starts at 2s)
|
|
1499
|
+
|
|
1500
|
+
/** `keyframedProject` with o0's scale authored some other way. */
|
|
1501
|
+
function overlayScaledBy(over: Record<string, unknown>): Project {
|
|
1502
|
+
const project = keyframedProject()
|
|
1503
|
+
const track = (project.tracks as { items: unknown[] }[])[1]
|
|
1504
|
+
track.items[0] = { ...(track.items[0] as object), ...over }
|
|
1505
|
+
return project
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
it('keys all five props at the clicked time, without moving the overlay', () => {
|
|
1509
|
+
const project = keyframedProject()
|
|
1510
|
+
const ctx = makeContext({ project, selectedIds: ['o0'] })
|
|
1511
|
+
const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
|
|
1512
|
+
|
|
1513
|
+
const next = visual(lastProjectChange(effects), 'o0')
|
|
1514
|
+
const before = visual(project, 'o0')
|
|
1515
|
+
for (const prop of KEYFRAME_PROPS) {
|
|
1516
|
+
expect(trackFor(next, prop)!.points.find(p => p.t === 1)).toBeDefined()
|
|
1517
|
+
// Nothing moved: the keyed value IS the value it already had there.
|
|
1518
|
+
expect(valueAt(next, prop, 1)).toBeCloseTo(valueAt(before, prop, 1))
|
|
1519
|
+
}
|
|
1520
|
+
// Existing keyframes are untouched — this ADDS a moment, it does not reset.
|
|
1521
|
+
expect(trackFor(next, 'offsetX')!.points.find(p => p.t === 0.5)).toBeDefined()
|
|
1522
|
+
expect(trackFor(next, 'offsetX')!.points.find(p => p.t === 1.5)).toBeDefined()
|
|
1523
|
+
expect(of(effects, 'commit')).toHaveLength(1)
|
|
1524
|
+
})
|
|
1525
|
+
|
|
1526
|
+
it('does NOT key an item that is not selected', () => {
|
|
1527
|
+
const ctx = makeContext({ project: keyframedProject() }) // selectedIds defaults to []
|
|
1528
|
+
const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
|
|
1529
|
+
expect(of(effects, 'projectChange')).toHaveLength(0)
|
|
1530
|
+
})
|
|
1531
|
+
|
|
1532
|
+
it('does NOT key a selected item when the double-click landed on a DIFFERENT item', () => {
|
|
1533
|
+
// Inverted on purpose: the click lands on o0, which IS keyframeable, and
|
|
1534
|
+
// c0 is what's selected. `canKeyframe` therefore cannot be what refuses —
|
|
1535
|
+
// the ONLY thing standing between this and a key is `selectedIds` not
|
|
1536
|
+
// containing 'o0'. The rule is "on the selected element", not "while
|
|
1537
|
+
// something is selected", and this is the case that separates the two
|
|
1538
|
+
// (test 2 above has an EMPTY selection, so it passes either way).
|
|
1539
|
+
const ctx = makeContext({ project: keyframedProject(), selectedIds: ['c0'] })
|
|
1540
|
+
const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
|
|
1541
|
+
expect(of(effects, 'projectChange')).toHaveLength(0)
|
|
1542
|
+
})
|
|
1543
|
+
|
|
1544
|
+
it('DOES key a selected video clip (SP9d)', () => {
|
|
1545
|
+
// canKeyframe widened when the renderer gained a per-frame geometry hook.
|
|
1546
|
+
const ctx = makeContext({ project: keyframedProject(), selectedIds: ['c0'] })
|
|
1547
|
+
const effects = new Driver(ctx).doubleClick(C0_BODY.x, C0_BODY.y)
|
|
1548
|
+
expect(of(effects, 'projectChange')).toHaveLength(1)
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
it('never writes an OPACITY track when keying a clip', () => {
|
|
1552
|
+
// ffmpeg cannot vary a clip's alpha, so an opacity track written here would
|
|
1553
|
+
// be silently ignored by the export — a keyframe that does nothing. The
|
|
1554
|
+
// exclusion lives in transformProps via canKeyframeProp.
|
|
1555
|
+
const ctx = makeContext({ project: keyframedProject(), selectedIds: ['c0'] })
|
|
1556
|
+
const effects = new Driver(ctx).doubleClick(C0_BODY.x, C0_BODY.y)
|
|
1557
|
+
const next = visual(lastProjectChange(effects), 'c0')
|
|
1558
|
+
expect(trackFor(next, 'opacity')).toBeUndefined()
|
|
1559
|
+
// ...while the geometry props it CAN animate are all keyed.
|
|
1560
|
+
expect(trackFor(next, 'scale')).toBeDefined()
|
|
1561
|
+
expect(trackFor(next, 'offsetX')).toBeDefined()
|
|
1562
|
+
expect(trackFor(next, 'rotation')).toBeDefined()
|
|
1563
|
+
})
|
|
1564
|
+
|
|
1565
|
+
it('never seeds scaleX/scaleY on a UNIFORM overlay — that would freeze its zoom', () => {
|
|
1566
|
+
// THE regression guard. `scaleX` shadows `scale` in the resolver's fallback
|
|
1567
|
+
// chain, so a one-point (i.e. constant) scaleX track seeded here would stop
|
|
1568
|
+
// this overlay's uniform zoom from ever animating again — silently, with
|
|
1569
|
+
// nothing on screen to say why, until the operator scrubs.
|
|
1570
|
+
const project = overlayScaledBy({
|
|
1571
|
+
keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 2, value: 2 }] }],
|
|
1572
|
+
})
|
|
1573
|
+
const ctx = makeContext({ project, selectedIds: ['o0'] })
|
|
1574
|
+
const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
|
|
1575
|
+
|
|
1576
|
+
const next = visual(lastProjectChange(effects), 'o0')
|
|
1577
|
+
expect(trackFor(next, 'scale')!.points.find(p => p.t === 1)).toBeDefined()
|
|
1578
|
+
expect(trackFor(next, 'scaleX')).toBeUndefined()
|
|
1579
|
+
expect(trackFor(next, 'scaleY')).toBeUndefined()
|
|
1580
|
+
// The zoom still animates, on both axes, exactly as it did before.
|
|
1581
|
+
expect(valueAt(next, 'scaleX', 0)).toBeCloseTo(1)
|
|
1582
|
+
expect(valueAt(next, 'scaleX', 2)).toBeCloseTo(2)
|
|
1583
|
+
expect(valueAt(next, 'scaleY', 0)).toBeCloseTo(1)
|
|
1584
|
+
expect(valueAt(next, 'scaleY', 2)).toBeCloseTo(2)
|
|
1585
|
+
})
|
|
1586
|
+
|
|
1587
|
+
it('keys scaleX/scaleY and NOT `scale` on a PER-AXIS overlay', () => {
|
|
1588
|
+
// The symmetric failure: keying `scale` on an item whose per-axis fields
|
|
1589
|
+
// already shadow it would make the whole gesture appear to do nothing.
|
|
1590
|
+
const project = overlayScaledBy({ scaleX: 1.5, scaleY: 0.5 })
|
|
1591
|
+
const ctx = makeContext({ project, selectedIds: ['o0'] })
|
|
1592
|
+
const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
|
|
1593
|
+
|
|
1594
|
+
const next = visual(lastProjectChange(effects), 'o0')
|
|
1595
|
+
const before = visual(project, 'o0')
|
|
1596
|
+
for (const prop of PER_AXIS_PROPS) {
|
|
1597
|
+
expect(trackFor(next, prop)!.points.find(p => p.t === 1)).toBeDefined()
|
|
1598
|
+
// Nothing moved, per-axis included.
|
|
1599
|
+
expect(valueAt(next, prop, 1)).toBeCloseTo(valueAt(before, prop, 1))
|
|
1600
|
+
}
|
|
1601
|
+
expect(trackFor(next, 'scale')).toBeUndefined()
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
it('follows a per-axis KEYFRAME TRACK, not just the per-axis scalars', () => {
|
|
1605
|
+
const project = overlayScaledBy({
|
|
1606
|
+
keyframes: [{ prop: 'scaleX', points: [{ t: 0, value: 1 }, { t: 2, value: 3 }] }],
|
|
1607
|
+
})
|
|
1608
|
+
const ctx = makeContext({ project, selectedIds: ['o0'] })
|
|
1609
|
+
const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
|
|
1610
|
+
|
|
1611
|
+
const next = visual(lastProjectChange(effects), 'o0')
|
|
1612
|
+
expect(trackFor(next, 'scaleX')!.points.find(p => p.t === 1)?.value).toBeCloseTo(2)
|
|
1613
|
+
expect(trackFor(next, 'scaleY')!.points.find(p => p.t === 1)).toBeDefined()
|
|
1614
|
+
expect(trackFor(next, 'scale')).toBeUndefined()
|
|
1615
|
+
})
|
|
1616
|
+
})
|
|
1617
|
+
|
|
1618
|
+
describe('no row is ever inert', () => {
|
|
1619
|
+
// Rows used to fade and go `pointer-events-none` on every track that didn't
|
|
1620
|
+
// hold the selection while a marker was down. With markers gone, nothing
|
|
1621
|
+
// dims for selection reasons and every row stays clickable.
|
|
1622
|
+
it('selects a clip on a track that holds none of the selection', () => {
|
|
1623
|
+
const d = new Driver(makeContext({ selectedIds: ['c0'] }))
|
|
1624
|
+
// o0 lives on track 1, which holds no part of the selection.
|
|
1625
|
+
d.down(300, OVERLAY_Y)
|
|
1626
|
+
expect(of(d.up(300, OVERLAY_Y), 'select')).toEqual([{ type: 'select', id: 'o0', additive: false }])
|
|
1627
|
+
})
|
|
1628
|
+
|
|
1629
|
+
it('keeps the row holding the selection live', () => {
|
|
1630
|
+
const d = new Driver(makeContext({ selectedIds: ['c0'] }))
|
|
1631
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
1632
|
+
expect(of(d.up(C0_BODY.x, C0_BODY.y), 'select')).toEqual([{ type: 'select', id: 'c0', additive: false }])
|
|
1633
|
+
})
|
|
1634
|
+
|
|
1635
|
+
it('keeps audio lanes live', () => {
|
|
1636
|
+
const d = new Driver(makeContext({ selectedIds: ['c0'] }))
|
|
1637
|
+
d.down(A0_BODY.x, A0_BODY.y)
|
|
1638
|
+
expect(of(d.up(A0_BODY.x, A0_BODY.y), 'select')).toEqual([{ type: 'select', id: 'a0', additive: false }])
|
|
1639
|
+
})
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
// ── Interruption and degenerate state ────────────────────────────────────
|
|
1643
|
+
|
|
1644
|
+
describe('cancel', () => {
|
|
1645
|
+
it('drops a gesture without committing', () => {
|
|
1646
|
+
const d = new Driver(makeContext())
|
|
1647
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
1648
|
+
d.move(C0_BODY.x + 100, C0_BODY.y)
|
|
1649
|
+
expect(of(d.cancel(), 'commit')).toEqual([])
|
|
1650
|
+
expect(d.machine.state.kind).toBe('idle')
|
|
1651
|
+
})
|
|
1652
|
+
|
|
1653
|
+
it('resets the cursor', () => {
|
|
1654
|
+
const d = new Driver(makeContext())
|
|
1655
|
+
d.move(C0_BODY.x, C0_BODY.y)
|
|
1656
|
+
expect(of(d.cancel(), 'cursor')).toEqual([{ type: 'cursor', cursor: 'default' }])
|
|
1657
|
+
})
|
|
1658
|
+
|
|
1659
|
+
it('leaves a released gesture alone', () => {
|
|
1660
|
+
const d = new Driver(makeContext())
|
|
1661
|
+
expect(d.cancel()).toEqual([])
|
|
1662
|
+
})
|
|
1663
|
+
})
|
|
1664
|
+
|
|
1665
|
+
// ── Press-time snap boundaries (self-snap regression) ────────────────────
|
|
1666
|
+
|
|
1667
|
+
describe('press-time snap boundaries — echoed-project self-snap', () => {
|
|
1668
|
+
// Live bug: VideoEditor echoes every `projectChange` back through Timeline's
|
|
1669
|
+
// re-render, and Timeline's `computeDerivedTiming` memo recomputes
|
|
1670
|
+
// `snapBoundaries` from THAT echoed project. Mid-gesture the boundary list
|
|
1671
|
+
// therefore contains the dragged item's own CURRENT (moved) edges, not just
|
|
1672
|
+
// the edges it started from. If a gesture read `ctx.snapBoundaries` live it
|
|
1673
|
+
// would find its last position back in its own magnet list and snap to
|
|
1674
|
+
// itself — repeatedly, since every subsequent move re-derives the same
|
|
1675
|
+
// trap. These tests drive the machine exactly the way Timeline does: apply
|
|
1676
|
+
// each `projectChange` to a running project and rebuild `snapBoundaries`
|
|
1677
|
+
// from it before the next move, the way the real memo would.
|
|
1678
|
+
function threeClipProject(): Project {
|
|
1679
|
+
return {
|
|
1680
|
+
id: 'p3',
|
|
1681
|
+
tracks: [
|
|
1682
|
+
{
|
|
1683
|
+
id: 'trk-0',
|
|
1684
|
+
items: [
|
|
1685
|
+
{ id: 'x0', type: 'video', src: 'a.mp4', start: 0, end: 3, inPoint: 0, outPoint: 3, sourceDuration: 20 },
|
|
1686
|
+
{ id: 'x1', type: 'video', src: 'b.mp4', start: 3, end: 6, inPoint: 0, outPoint: 3, sourceDuration: 20 },
|
|
1687
|
+
{ id: 'x2', type: 'video', src: 'c.mp4', start: 6, end: 9, inPoint: 0, outPoint: 3, sourceDuration: 20 },
|
|
1688
|
+
],
|
|
1689
|
+
},
|
|
1690
|
+
],
|
|
1691
|
+
} as unknown as Project
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/** Timeline's live memo, reproduced: fold the latest `projectChange` into a
|
|
1695
|
+
* running project and rebuild `snapBoundaries` (and `totalDuration`) from
|
|
1696
|
+
* it, exactly as `computeDerivedTiming` would on the next render. */
|
|
1697
|
+
function echoProjectChange(ctx: PointerContext, effects: PointerEffect[]): PointerContext {
|
|
1698
|
+
const changes = of(effects, 'projectChange')
|
|
1699
|
+
if (changes.length === 0) return ctx
|
|
1700
|
+
const project = changes[changes.length - 1].project
|
|
1701
|
+
const derived = computeDerivedTiming(project)
|
|
1702
|
+
return { ...ctx, project, snapBoundaries: derived.snapBoundaries, totalDuration: derived.totalDuration }
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
it('tracks the cursor through a body drag instead of magnetizing to its own echoed position', () => {
|
|
1706
|
+
const project = threeClipProject()
|
|
1707
|
+
const layout = computeTimelineLayout(project)
|
|
1708
|
+
const row = layout.rows.find(r => r.trackIdx === 0)!
|
|
1709
|
+
const y = row.y + row.height / 2
|
|
1710
|
+
const bodyX = 4 * VIEWPORT.pxPerSecond // inside x1's body (3s–6s), clear of both edge handles
|
|
1711
|
+
|
|
1712
|
+
const d = new Driver(makeContext({ project }))
|
|
1713
|
+
d.down(bodyX, y)
|
|
1714
|
+
|
|
1715
|
+
const starts: number[] = []
|
|
1716
|
+
for (let i = 1; i <= 12; i++) {
|
|
1717
|
+
const effects = d.move(bodyX + i * 4, y)
|
|
1718
|
+
d.ctx = echoProjectChange(d.ctx, effects)
|
|
1719
|
+
starts.push(visual(lastProjectChange(effects), 'x1').start)
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Must track the cursor — not plateau at an echoed position and jump.
|
|
1723
|
+
for (let i = 1; i < starts.length; i++) {
|
|
1724
|
+
expect(starts[i]).toBeGreaterThanOrEqual(starts[i - 1] - 1e-9)
|
|
1725
|
+
}
|
|
1726
|
+
expect(new Set(starts.map(s => s.toFixed(6))).size).toBeGreaterThanOrEqual(10)
|
|
1727
|
+
expect(starts[starts.length - 1]).toBeCloseTo(3 + 48 / VIEWPORT.pxPerSecond, 6)
|
|
1728
|
+
})
|
|
1729
|
+
|
|
1730
|
+
it('tracks the cursor through an edge trim instead of freezing at its own echoed position', () => {
|
|
1731
|
+
const project = threeClipProject()
|
|
1732
|
+
const layout = computeTimelineLayout(project)
|
|
1733
|
+
const row = layout.rows.find(r => r.trackIdx === 0)!
|
|
1734
|
+
const y = row.y + row.height / 2
|
|
1735
|
+
const endEdgeX = 6 * VIEWPORT.pxPerSecond - 2 // inside x1's out-edge handle (end = 6s)
|
|
1736
|
+
|
|
1737
|
+
const d = new Driver(makeContext({ project }))
|
|
1738
|
+
d.down(endEdgeX, y)
|
|
1739
|
+
|
|
1740
|
+
const ends: number[] = []
|
|
1741
|
+
for (let i = 1; i <= 5; i++) {
|
|
1742
|
+
const effects = d.move(endEdgeX - i * 4, y)
|
|
1743
|
+
d.ctx = echoProjectChange(d.ctx, effects)
|
|
1744
|
+
ends.push(visual(lastProjectChange(effects), 'x1').end)
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Must not freeze at the first applied position.
|
|
1748
|
+
expect(new Set(ends.map(e => e.toFixed(6))).size).toBeGreaterThanOrEqual(5)
|
|
1749
|
+
expect(ends[ends.length - 1]).toBeCloseTo(6 - 20 / VIEWPORT.pxPerSecond, 6)
|
|
1750
|
+
})
|
|
1751
|
+
})
|
|
1752
|
+
|
|
1753
|
+
// ── The snap guide ───────────────────────────────────────────────────────
|
|
1754
|
+
//
|
|
1755
|
+
// The magnet is deliberately hard to escape (44px of release against 20px of
|
|
1756
|
+
// attract), which is only usable because the guide says where you are held.
|
|
1757
|
+
// These tests are the contract between the two: the machine reports the
|
|
1758
|
+
// BOUNDARY and its tier, once per change, and takes it down the moment the
|
|
1759
|
+
// gesture ends.
|
|
1760
|
+
|
|
1761
|
+
const guide = (time: number | null, strength: 'strong' | 'weak' | null) =>
|
|
1762
|
+
[{ type: 'snapGuide', time, strength }]
|
|
1763
|
+
|
|
1764
|
+
describe('snap guide', () => {
|
|
1765
|
+
it('raises a guide on capture and drops it on release, once each', () => {
|
|
1766
|
+
const d = new Driver(makeContext())
|
|
1767
|
+
|
|
1768
|
+
// Pressing at 5.05s captures the cut at 5. A scrub tiers everything
|
|
1769
|
+
// strong: the playhead belongs to no row, so it has no "own track".
|
|
1770
|
+
d.down(505, RULER_Y)
|
|
1771
|
+
expect(of(d.effects, 'snapGuide')).toEqual(guide(5, 'strong'))
|
|
1772
|
+
|
|
1773
|
+
// Still held at 5.25s — past attract, well inside release. The guide has
|
|
1774
|
+
// not MOVED, so nothing is emitted: a drag held against one boundary must
|
|
1775
|
+
// not spray an effect per pointer event.
|
|
1776
|
+
expect(of(d.move(525, RULER_Y), 'snapGuide')).toEqual([])
|
|
1777
|
+
|
|
1778
|
+
// 5.6s clears release.
|
|
1779
|
+
expect(of(d.move(560, RULER_Y), 'snapGuide')).toEqual(guide(null, null))
|
|
1780
|
+
// …and stays down without re-announcing itself.
|
|
1781
|
+
expect(of(d.move(580, RULER_Y), 'snapGuide')).toEqual([])
|
|
1782
|
+
})
|
|
1783
|
+
|
|
1784
|
+
it('takes the guide down when a gesture ends still snapped', () => {
|
|
1785
|
+
const d = new Driver(makeContext())
|
|
1786
|
+
d.down(505, RULER_Y)
|
|
1787
|
+
expect(of(d.up(505, RULER_Y), 'snapGuide')).toEqual(guide(null, null))
|
|
1788
|
+
})
|
|
1789
|
+
|
|
1790
|
+
it('takes the guide down on a cancelled gesture', () => {
|
|
1791
|
+
const d = new Driver(makeContext())
|
|
1792
|
+
d.down(505, RULER_Y)
|
|
1793
|
+
expect(of(d.cancel(), 'snapGuide')).toEqual(guide(null, null))
|
|
1794
|
+
})
|
|
1795
|
+
|
|
1796
|
+
it('marks the boundary a dragged clip\'s TAIL caught, not its start', () => {
|
|
1797
|
+
// o0 is 2s–4s on track 1. Dragged one second right its END lands exactly
|
|
1798
|
+
// on track 0's cut at 5 — the magnet's snap point is the START it implies
|
|
1799
|
+
// (3), which is a whole clip away from the edge the user is watching.
|
|
1800
|
+
const d = new Driver(makeContext())
|
|
1801
|
+
d.down(300, OVERLAY_Y)
|
|
1802
|
+
expect(of(d.move(400, OVERLAY_Y), 'snapGuide')).toEqual(guide(5, 'weak'))
|
|
1803
|
+
})
|
|
1804
|
+
|
|
1805
|
+
it('marks the boundary a dragged clip\'s HEAD caught', () => {
|
|
1806
|
+
// Same clip one second left: its start lands on the audio bar's start at 1.
|
|
1807
|
+
const d = new Driver(makeContext())
|
|
1808
|
+
d.down(300, OVERLAY_Y)
|
|
1809
|
+
expect(of(d.move(200, OVERLAY_Y), 'snapGuide')).toEqual(guide(1, 'weak'))
|
|
1810
|
+
})
|
|
1811
|
+
|
|
1812
|
+
it('marks a same-track trim as strong', () => {
|
|
1813
|
+
// c0's out edge pulled all the way out to c1's end at 10 — the same row,
|
|
1814
|
+
// so the full magnet and a bold guide.
|
|
1815
|
+
const d = new Driver(makeContext())
|
|
1816
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
1817
|
+
expect(of(d.move(995, C0_OUT_EDGE.y), 'snapGuide')).toEqual(guide(10, 'strong'))
|
|
1818
|
+
})
|
|
1819
|
+
|
|
1820
|
+
it('marks a cross-track trim as weak', () => {
|
|
1821
|
+
// Same gesture, aimed instead at o0's end at 4 — one row up. It still
|
|
1822
|
+
// catches, because 4.05 is 5px away and the weak radius is 6, but it
|
|
1823
|
+
// announces itself as the hint it is.
|
|
1824
|
+
const d = new Driver(makeContext())
|
|
1825
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
1826
|
+
expect(of(d.move(400, C0_OUT_EDGE.y), 'snapGuide')).toEqual(guide(4, 'weak'))
|
|
1827
|
+
})
|
|
1828
|
+
|
|
1829
|
+
it('lets a cross-track boundary go by at a distance a same-track one would hold', () => {
|
|
1830
|
+
// 4.10 is 10px from o0's end: inside the strong radius, outside the weak
|
|
1831
|
+
// one. Before the tiers this snapped, and dragging the base video track
|
|
1832
|
+
// past a busy overlay row was a fight.
|
|
1833
|
+
const d = new Driver(makeContext())
|
|
1834
|
+
d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
|
|
1835
|
+
expect(of(d.move(410, C0_OUT_EDGE.y), 'snapGuide')).toEqual([])
|
|
1836
|
+
})
|
|
1837
|
+
|
|
1838
|
+
it('shows no guide when the op refuses to put the edge where the magnet asked', () => {
|
|
1839
|
+
// o0's out edge dragged back past its own start: the magnet catches t=0,
|
|
1840
|
+
// but a clip cannot be shorter than 0.1s, so the edge lands at 2.1 and the
|
|
1841
|
+
// alignment never happens. A guide at 0 would be pointing at nothing.
|
|
1842
|
+
const d = new Driver(makeContext())
|
|
1843
|
+
d.down(397, OVERLAY_Y)
|
|
1844
|
+
expect(of(d.move(2, OVERLAY_Y), 'snapGuide')).toEqual([])
|
|
1845
|
+
})
|
|
1846
|
+
|
|
1847
|
+
it('tiers audio by LANE, so two bars sharing a lane pull on each other', () => {
|
|
1848
|
+
// Lanes, not tracks: a lane can hold several bars and reads as one row, and
|
|
1849
|
+
// a bar with no explicit `lane` gets an auto-assigned one. Reading
|
|
1850
|
+
// `track.lane ?? 0` instead of grouping would file every unlabelled bar
|
|
1851
|
+
// under lane 0 and call them all same-lane.
|
|
1852
|
+
const project = {
|
|
1853
|
+
id: 'p',
|
|
1854
|
+
tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 12, inPoint: 0, outPoint: 12, sourceDuration: 20 }] }],
|
|
1855
|
+
audio: {
|
|
1856
|
+
tracks: [
|
|
1857
|
+
{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 },
|
|
1858
|
+
{ id: 'a1', src: 'w.mp3', start: 8, end: 11, lane: 0 },
|
|
1859
|
+
{ id: 'b0', src: 'm.mp3', start: 3, end: 4, lane: 1 },
|
|
1860
|
+
],
|
|
1861
|
+
},
|
|
1862
|
+
} as unknown as Project
|
|
1863
|
+
const ctx = makeContext({ project })
|
|
1864
|
+
const lane0 = ctx.layout.lanes.find(l => l.laneIndex === 0)!
|
|
1865
|
+
const y = Math.round(lane0.y + lane0.height / 2)
|
|
1866
|
+
|
|
1867
|
+
// a0's out edge (t=6, x=600) dragged to 8.1 — a1's start, same lane. 10px
|
|
1868
|
+
// out, so only the strong radius can reach it.
|
|
1869
|
+
const strongDrag = new Driver(ctx)
|
|
1870
|
+
strongDrag.down(597, y)
|
|
1871
|
+
expect(of(strongDrag.move(810, y), 'snapGuide')).toEqual(guide(8, 'strong'))
|
|
1872
|
+
|
|
1873
|
+
// The same edge dragged to 4.04 — b0's end, one lane down, 4px out. It
|
|
1874
|
+
// catches, but only just, and announces itself as the hint it is.
|
|
1875
|
+
const weakDrag = new Driver(ctx)
|
|
1876
|
+
weakDrag.down(597, y)
|
|
1877
|
+
expect(of(weakDrag.move(401, y), 'snapGuide')).toEqual(guide(4, 'weak'))
|
|
1878
|
+
})
|
|
1879
|
+
|
|
1880
|
+
it('never guides a slip, which has no timeline snap points at all', () => {
|
|
1881
|
+
const d = new Driver(makeContext())
|
|
1882
|
+
d.down(C0_BODY.x, C0_BODY.y, mods({ alt: true }))
|
|
1883
|
+
expect(of(d.move(C0_BODY.x + 100, C0_BODY.y, mods({ alt: true })), 'snapGuide')).toEqual([])
|
|
1884
|
+
})
|
|
1885
|
+
})
|
|
1886
|
+
|
|
1887
|
+
// A single-item MOVE is the one gesture whose strong snap tier follows the
|
|
1888
|
+
// row the item is CURRENTLY over rather than the row the press landed on —
|
|
1889
|
+
// see `tieredBoundaries`'s doc comment for why every other gesture keeps the
|
|
1890
|
+
// press-time row for its whole run. These tests drag `sameKindProject`'s c1
|
|
1891
|
+
// (track 0, 5s–10s) up onto track 1 (o0, video-kind here so the kind-lock
|
|
1892
|
+
// doesn't block the move) and prove the tiers actually flip as it crosses.
|
|
1893
|
+
describe('cross-track re-tiering — a move follows the row it is currently over', () => {
|
|
1894
|
+
it('promotes the destination track\'s boundary to strong once the drag reaches it', () => {
|
|
1895
|
+
// rawStart lands on 4.08 — 8px short of o0's end (4s, track 1). Outside
|
|
1896
|
+
// the weak radius (6px), so at rest on track 0 (o0's boundary tiered
|
|
1897
|
+
// weak) it does not catch at all.
|
|
1898
|
+
const atRest = new Driver(makeContext({ project: sameKindProject() }))
|
|
1899
|
+
atRest.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
1900
|
+
expect(of(atRest.move(658, SAME_KIND_BASE_Y), 'snapGuide')).toEqual([])
|
|
1901
|
+
|
|
1902
|
+
// The identical horizontal drag, but crossed onto track 1: o0's boundary
|
|
1903
|
+
// is now the CURRENT track's peer, promoted to strong, and the same 8px
|
|
1904
|
+
// is well inside its 20px radius.
|
|
1905
|
+
const crossed = new Driver(makeContext({ project: sameKindProject() }))
|
|
1906
|
+
crossed.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
1907
|
+
expect(of(crossed.move(658, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX), 'snapGuide')).toEqual(guide(4, 'strong'))
|
|
1908
|
+
})
|
|
1909
|
+
|
|
1910
|
+
it('demotes the origin track\'s boundary to weak once the drag has left it', () => {
|
|
1911
|
+
// Target is c0's START (0s), not its end (5s) — 5s is butted against c1's
|
|
1912
|
+
// own start and would be suppressed by the origin guard at this short a
|
|
1913
|
+
// travel distance, which is a different mechanism from the one this test
|
|
1914
|
+
// is about. rawStart lands on 0.15 — 15px short of 0: inside the strong
|
|
1915
|
+
// radius (20px), outside the weak one (6px).
|
|
1916
|
+
//
|
|
1917
|
+
// The playhead is parked well away from 0 (rather than left at the
|
|
1918
|
+
// default 0) — it is ALWAYS a strong candidate regardless of track, and
|
|
1919
|
+
// at the default it would coincide with this test's own target and mask
|
|
1920
|
+
// the tier this test is actually checking.
|
|
1921
|
+
const ctx = makeContext({ project: sameKindProject(), playheadTime: -1000 })
|
|
1922
|
+
const atRest = new Driver(ctx)
|
|
1923
|
+
atRest.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
1924
|
+
expect(of(atRest.move(265, SAME_KIND_BASE_Y), 'snapGuide')).toEqual(guide(0, 'strong'))
|
|
1925
|
+
|
|
1926
|
+
// Crossed onto track 1, track 0 — the track this drag LEFT — demotes to
|
|
1927
|
+
// weak, and the same 15px is now too far even for that.
|
|
1928
|
+
const crossed = new Driver(ctx)
|
|
1929
|
+
crossed.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
1930
|
+
expect(of(crossed.move(265, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX), 'snapGuide')).toEqual([])
|
|
1931
|
+
})
|
|
1932
|
+
|
|
1933
|
+
it('keeps the playhead strong no matter which track the drag is currently over', () => {
|
|
1934
|
+
// The playhead belongs to no track, so re-tiering must never touch it.
|
|
1935
|
+
// rawStart lands exactly on 3.5, an arbitrary playhead time that shares no
|
|
1936
|
+
// value with any clip or audio boundary in the fixture.
|
|
1937
|
+
const ctx = makeContext({ project: sameKindProject(), playheadTime: 3.5 })
|
|
1938
|
+
const atRest = new Driver(ctx)
|
|
1939
|
+
atRest.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
1940
|
+
expect(of(atRest.move(600, SAME_KIND_BASE_Y), 'snapGuide')).toEqual(guide(3.5, 'strong'))
|
|
1941
|
+
|
|
1942
|
+
const crossed = new Driver(ctx)
|
|
1943
|
+
crossed.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
1944
|
+
expect(of(crossed.move(600, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX), 'snapGuide')).toEqual(guide(3.5, 'strong'))
|
|
1945
|
+
})
|
|
1946
|
+
|
|
1947
|
+
it('still excludes the dragged item\'s own edges once re-tiered onto a new track', () => {
|
|
1948
|
+
// Aimed exactly at c1's own original end (10s) after crossing onto track
|
|
1949
|
+
// 1. `tieredBoundaries`'s `skipId` drops a dragged item's own boundaries
|
|
1950
|
+
// by IDENTITY, not by value, and that has to keep working when the tier
|
|
1951
|
+
// is recomputed fresh per move — a regression here would have c1 snap to
|
|
1952
|
+
// its own trailing edge as an exact-match WEAK point (tiered against
|
|
1953
|
+
// track 0, which it no longer occupies) the moment it crossed rows.
|
|
1954
|
+
const d = new Driver(makeContext({ project: sameKindProject() }))
|
|
1955
|
+
d.down(C1_BODY.x, SAME_KIND_BASE_Y)
|
|
1956
|
+
expect(of(d.move(1250, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX), 'snapGuide')).toEqual([])
|
|
1957
|
+
})
|
|
1958
|
+
|
|
1959
|
+
it('leaves a within-track move exactly as it was — the current track equals the origin track throughout', () => {
|
|
1960
|
+
// No vertical travel at all: c0 dragged along track 0 toward c1's own
|
|
1961
|
+
// trailing edge at 10s, 8px short — same-track, so still strong.
|
|
1962
|
+
const d = new Driver(makeContext({ project: sameKindProject() }))
|
|
1963
|
+
d.down(C0_BODY.x, SAME_KIND_BASE_Y)
|
|
1964
|
+
expect(of(d.move(1242, SAME_KIND_BASE_Y), 'snapGuide')).toEqual(guide(10, 'strong'))
|
|
1965
|
+
})
|
|
1966
|
+
|
|
1967
|
+
it('tiers against the track the drop LANDS on, not the one the vertical travel points at', () => {
|
|
1968
|
+
// Sam's repro, 2026-08-25: an overlay exactly as long as a gap one row
|
|
1969
|
+
// below, dragged down into that gap, showed no guide and refused to click
|
|
1970
|
+
// into place — while landing on the right row the whole time.
|
|
1971
|
+
//
|
|
1972
|
+
// The two facts that produce it, neither visible from the drag alone:
|
|
1973
|
+
//
|
|
1974
|
+
// - The drag divisor (VISUAL_ROW_HEIGHT_PX, 24) is roughly HALF a
|
|
1975
|
+
// rendered overlay row (40 + 4 gap = 44), so one row of real travel
|
|
1976
|
+
// reads as TWO track steps. From track 2 that points at track 0.
|
|
1977
|
+
// - `moveItemAcrossTracks` does not honour that index: track 0 holds
|
|
1978
|
+
// video, the dragged item is an overlay, so the kind gate rejects it
|
|
1979
|
+
// and the outward search puts the drop back on track 1 — the row the
|
|
1980
|
+
// cursor is genuinely over.
|
|
1981
|
+
//
|
|
1982
|
+
// The drop was therefore right and only the TIER was wrong: it ranked
|
|
1983
|
+
// track 0's boundaries strong and left the gap's own two edges (2s and
|
|
1984
|
+
// 7s, on track 1) weak, a 6px magnet where a 20px one belongs.
|
|
1985
|
+
const project = {
|
|
1986
|
+
id: 'p',
|
|
1987
|
+
tracks: [
|
|
1988
|
+
{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 12, inPoint: 0, outPoint: 12, sourceDuration: 20 }] },
|
|
1989
|
+
// The gap: 2s–7s, five seconds wide.
|
|
1990
|
+
{ id: 'trk-1', items: [{ id: 'g0', type: 'overlay', start: 0, end: 2 }, { id: 'g1', type: 'overlay', start: 7, end: 12 }] },
|
|
1991
|
+
// Five seconds wide too — it fits the gap exactly.
|
|
1992
|
+
{ id: 'trk-2', items: [{ id: 'ov', type: 'overlay', start: 2.6, end: 7.6 }] },
|
|
1993
|
+
],
|
|
1994
|
+
} as unknown as Project
|
|
1995
|
+
const ctx = makeContext({ project })
|
|
1996
|
+
const rowY = (trackIdx: number) => {
|
|
1997
|
+
const row = ctx.layout.rows.find(r => r.trackIdx === trackIdx)!
|
|
1998
|
+
return Math.round(row.y + row.height / 2)
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const d = new Driver(ctx)
|
|
2002
|
+
d.down(510, rowY(2)) // ov's midpoint
|
|
2003
|
+
// Straight down onto track 1's row, no horizontal travel: 44px, which the
|
|
2004
|
+
// 24px divisor reads as two track steps.
|
|
2005
|
+
d.move(510, rowY(1))
|
|
2006
|
+
// It really did land on track 1 — the row under the cursor.
|
|
2007
|
+
expect(trackIndexOf(lastProjectChange(d.effects), 'ov')).toBe(1)
|
|
2008
|
+
|
|
2009
|
+
// Now slide left toward the gap's leading edge at 2s. rawStart is 2.18 —
|
|
2010
|
+
// 18px out: inside the strong radius (20px), outside the weak one (6px).
|
|
2011
|
+
// Weak, this is nothing; strong, it catches and the clip drops into the
|
|
2012
|
+
// gap it fits.
|
|
2013
|
+
expect(of(d.move(468, rowY(1)), 'snapGuide')).toEqual(guide(2, 'strong'))
|
|
2014
|
+
expect(visual(lastProjectChange(d.effects), 'ov')).toMatchObject({ start: 2, end: 7 })
|
|
2015
|
+
})
|
|
2016
|
+
|
|
2017
|
+
it('catches the TRAILING edge too — approached from the left, the clip\'s END snaps to the gap\'s far edge', () => {
|
|
2018
|
+
// Same gap (track 1, 2s–7s) and the same double-hop divisor mechanic as
|
|
2019
|
+
// the repro above, but driven from the other side and with a dragged item
|
|
2020
|
+
// that does NOT fill the gap (3s wide, not 5s) — so `snapPointsForSpan`'s
|
|
2021
|
+
// head formula (`p`) and tail formula (`p - duration`) land on two
|
|
2022
|
+
// DIFFERENT numbers, and only the tail one is close enough to catch. The
|
|
2023
|
+
// repro above can't tell the two apart: its item is exactly gap-width, so
|
|
2024
|
+
// both formulas resolve to the same value regardless of which edge is
|
|
2025
|
+
// "really" catching.
|
|
2026
|
+
//
|
|
2027
|
+
// A revert back to tiering the POINTED-AT index (`sourceTrackIdx -
|
|
2028
|
+
// round(dy / 24)`) puts track 0's cuts (0, 12) in the strong tier instead
|
|
2029
|
+
// and drops the gap's own edges (2, 7) to weak — 15px is inside the
|
|
2030
|
+
// strong radius (20px) but outside the weak one (6px), so on a revert
|
|
2031
|
+
// nothing catches at all and this assertion fails.
|
|
2032
|
+
const project = {
|
|
2033
|
+
id: 'p',
|
|
2034
|
+
tracks: [
|
|
2035
|
+
{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 12, inPoint: 0, outPoint: 12, sourceDuration: 20 }] },
|
|
2036
|
+
// The gap: 2s–7s, same as the repro above.
|
|
2037
|
+
{ id: 'trk-1', items: [{ id: 'g0', type: 'overlay', start: 0, end: 2 }, { id: 'g1', type: 'overlay', start: 7, end: 12 }] },
|
|
2038
|
+
// 3s wide — shorter than the gap, unlike the repro above — and parked
|
|
2039
|
+
// INSIDE it (3s–6s) rather than butted against either neighbour, so
|
|
2040
|
+
// the first, purely-vertical move below doesn't trip the (unrelated)
|
|
2041
|
+
// collision gate the real drop still runs: `resolveTargetTrackIdx`'s
|
|
2042
|
+
// `kindOnly` skip only applies to the TIER computation, not to
|
|
2043
|
+
// `moveItemAcrossTracks`'s own search for where the item actually
|
|
2044
|
+
// lands.
|
|
2045
|
+
{ id: 'trk-2', items: [{ id: 'ov', type: 'overlay', start: 3, end: 6 }] },
|
|
2046
|
+
],
|
|
2047
|
+
} as unknown as Project
|
|
2048
|
+
const ctx = makeContext({ project })
|
|
2049
|
+
const rowY = (trackIdx: number) => {
|
|
2050
|
+
const row = ctx.layout.rows.find(r => r.trackIdx === trackIdx)!
|
|
2051
|
+
return Math.round(row.y + row.height / 2)
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
const d = new Driver(ctx)
|
|
2055
|
+
d.down(450, rowY(2)) // ov's body (3s–6s)
|
|
2056
|
+
d.move(450, rowY(1)) // straight down onto track 1's row
|
|
2057
|
+
// It really did land on track 1, same as the repro above.
|
|
2058
|
+
expect(trackIndexOf(lastProjectChange(d.effects), 'ov')).toBe(1)
|
|
2059
|
+
|
|
2060
|
+
// Slide right so the clip's END approaches the gap's far edge (7s).
|
|
2061
|
+
// rawStart lands on 4.15 — 15px past the tail candidate (7 − 3 = 4):
|
|
2062
|
+
// inside the strong radius, outside the weak one, and nothing else is
|
|
2063
|
+
// anywhere near 4.15 — so this is the tail formula catching alone.
|
|
2064
|
+
expect(of(d.move(565, rowY(1)), 'snapGuide')).toEqual(guide(7, 'strong'))
|
|
2065
|
+
expect(visual(lastProjectChange(d.effects), 'ov')).toMatchObject({ start: 4, end: 7 })
|
|
2066
|
+
})
|
|
2067
|
+
})
|
|
2068
|
+
|
|
2069
|
+
describe('cross-lane re-tiering — a single-item audio move follows the lane it is currently over', () => {
|
|
2070
|
+
it('promotes the destination lane\'s boundary to strong, and demotes the origin lane\'s', () => {
|
|
2071
|
+
const project = {
|
|
2072
|
+
id: 'p',
|
|
2073
|
+
tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 12, inPoint: 0, outPoint: 12, sourceDuration: 20 }] }],
|
|
2074
|
+
audio: {
|
|
2075
|
+
tracks: [
|
|
2076
|
+
{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 },
|
|
2077
|
+
{ id: 'a1', src: 'w.mp3', start: 8, end: 11, lane: 0 },
|
|
2078
|
+
{ id: 'b0', src: 'm.mp3', start: 3, end: 4, lane: 1 },
|
|
2079
|
+
],
|
|
2080
|
+
},
|
|
2081
|
+
} as unknown as Project
|
|
2082
|
+
const ctx = makeContext({ project })
|
|
2083
|
+
const lane0 = ctx.layout.lanes.find(l => l.laneIndex === 0)!
|
|
2084
|
+
const y0 = Math.round(lane0.y + lane0.height / 2)
|
|
2085
|
+
const lane1 = ctx.layout.lanes.find(l => l.laneIndex === 1)!
|
|
2086
|
+
const y1 = Math.round(lane1.y + lane1.height / 2)
|
|
2087
|
+
|
|
2088
|
+
// a0 (1s–6s) dragged toward b0's end at 4s, 8px short — outside the weak
|
|
2089
|
+
// radius, so at rest in lane 0 (b0's boundary tiered weak) it misses.
|
|
2090
|
+
const missesAtRest = new Driver(ctx)
|
|
2091
|
+
missesAtRest.down(300, y0)
|
|
2092
|
+
expect(of(missesAtRest.move(608, y0), 'snapGuide')).toEqual([])
|
|
2093
|
+
|
|
2094
|
+
// The same drag, crossed into lane 1: b0's boundary promotes to strong
|
|
2095
|
+
// and the same 8px catches.
|
|
2096
|
+
const catchesCrossed = new Driver(ctx)
|
|
2097
|
+
catchesCrossed.down(300, y0)
|
|
2098
|
+
expect(of(catchesCrossed.move(608, y1), 'snapGuide')).toEqual(guide(4, 'strong'))
|
|
2099
|
+
|
|
2100
|
+
// a1 (lane 0, a0's own origin lane) is a strong peer at rest — 15px is
|
|
2101
|
+
// inside the strong radius and outside the weak one.
|
|
2102
|
+
const holdsAtRest = new Driver(ctx)
|
|
2103
|
+
holdsAtRest.down(300, y0)
|
|
2104
|
+
expect(of(holdsAtRest.move(1015, y0), 'snapGuide')).toEqual(guide(8, 'strong'))
|
|
2105
|
+
|
|
2106
|
+
// Crossed into lane 1, lane 0 — the lane this drag LEFT — demotes to
|
|
2107
|
+
// weak, and the same 15px is now too far even for that.
|
|
2108
|
+
const releasesCrossed = new Driver(ctx)
|
|
2109
|
+
releasesCrossed.down(300, y0)
|
|
2110
|
+
expect(of(releasesCrossed.move(1015, y1), 'snapGuide')).toEqual([])
|
|
2111
|
+
})
|
|
2112
|
+
})
|
|
2113
|
+
|
|
2114
|
+
describe('degenerate context', () => {
|
|
2115
|
+
it('edits nothing before the surface has a scale', () => {
|
|
2116
|
+
const unscaled: Viewport = { pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }
|
|
2117
|
+
const d = new Driver(makeContext({ viewport: unscaled }))
|
|
2118
|
+
// Every clip collapses to zero width, so a press lands on empty track area.
|
|
2119
|
+
d.down(0, 60)
|
|
2120
|
+
expect(of(d.move(200, 60), 'projectChange')).toEqual([])
|
|
2121
|
+
})
|
|
2122
|
+
|
|
2123
|
+
it('starts idle with the default cursor', () => {
|
|
2124
|
+
expect(initialMachineState()).toEqual({ kind: 'idle', cursor: 'default' })
|
|
2125
|
+
})
|
|
2126
|
+
|
|
2127
|
+
it('is a pure reducer — the same input twice gives the same output', () => {
|
|
2128
|
+
const ctx = makeContext()
|
|
2129
|
+
const event = { type: 'pointerDown' as const, point: { x: 700, y: 20 }, modifiers: NO_MODIFIERS, ctx }
|
|
2130
|
+
const a = pointerReducer(initialMachineState(), event)
|
|
2131
|
+
const b = pointerReducer(initialMachineState(), event)
|
|
2132
|
+
expect(a.effects).toEqual(b.effects)
|
|
2133
|
+
expect(a.state).toEqual(b.state)
|
|
2134
|
+
})
|
|
2135
|
+
|
|
2136
|
+
it('never mutates the project it is given', () => {
|
|
2137
|
+
const ctx = makeContext()
|
|
2138
|
+
const before = JSON.stringify(ctx.project)
|
|
2139
|
+
const d = new Driver(ctx)
|
|
2140
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
2141
|
+
d.move(C0_BODY.x + 100, C0_BODY.y)
|
|
2142
|
+
d.up(C0_BODY.x + 100, C0_BODY.y)
|
|
2143
|
+
expect(JSON.stringify(ctx.project)).toBe(before)
|
|
2144
|
+
})
|
|
2145
|
+
})
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
// ── Butted neighbours (the narration-snapping regression) ────────────────
|
|
2149
|
+
|
|
2150
|
+
/**
|
|
2151
|
+
* Voice-over is recorded as takes that butt end-to-end, so a voice clip's start
|
|
2152
|
+
* is numerically equal to its neighbour's end. Snap origins used to be excluded
|
|
2153
|
+
* by VALUE, which deleted the neighbour's boundary along with the clip's own —
|
|
2154
|
+
* handing every butted clip a timeline whose one useful magnet had been quietly
|
|
2155
|
+
* removed. Excluding by identity keeps the neighbour; `originGuard` suppresses
|
|
2156
|
+
* it only while the gesture is still sitting on it.
|
|
2157
|
+
*/
|
|
2158
|
+
describe('butted neighbours snap back', () => {
|
|
2159
|
+
/** Two audio bars sharing lane 0, butted at `join`, over a video bed. */
|
|
2160
|
+
function butted(join: number, secondEnd: number): Project {
|
|
2161
|
+
return {
|
|
2162
|
+
id: 'p',
|
|
2163
|
+
tracks: [{ id: 'trk-0', items: [
|
|
2164
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 30, inPoint: 0, outPoint: 30, sourceDuration: 60 },
|
|
2165
|
+
] }],
|
|
2166
|
+
audio: { tracks: [
|
|
2167
|
+
{ id: 'vo1', src: 'vo1.wav', start: 0, end: join, lane: 0 },
|
|
2168
|
+
{ id: 'vo2', src: 'vo2.wav', start: join, end: secondEnd, lane: 0 },
|
|
2169
|
+
] },
|
|
2170
|
+
} as unknown as Project
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
/** Grab vo2's body, optionally pull well clear first, then land its start at
|
|
2174
|
+
* `landAt` seconds. `viaEscape` is what a real drag away-and-back does; the
|
|
2175
|
+
* origin boundary only re-arms once the gesture has cleared it. */
|
|
2176
|
+
function dragVo2(
|
|
2177
|
+
ctx: PointerContext, grabT: number, from: number, landAt: number,
|
|
2178
|
+
{ viaEscape = true } = {},
|
|
2179
|
+
) {
|
|
2180
|
+
const lane = ctx.layout.lanes[0]
|
|
2181
|
+
const y = Math.round(lane.y + lane.height / 2)
|
|
2182
|
+
const d = new Driver(ctx)
|
|
2183
|
+
const originX = grabT * 100
|
|
2184
|
+
d.down(originX, y)
|
|
2185
|
+
if (viaEscape) d.move(originX + 300, y) // 300px out, far past the 44px release
|
|
2186
|
+
return d.move(originX + (landAt - from) * 100, y)
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
it('snaps a butted bar back onto its neighbour once pulled clear', () => {
|
|
2190
|
+
const project = butted(4, 9)
|
|
2191
|
+
const ctx = makeContext({ project, viewport: VIEWPORT, ...computeDerivedTiming(project) })
|
|
2192
|
+
// Grabbed at t=6, aiming vo2's start at 4.08 — 8px past vo1's end, well
|
|
2193
|
+
// inside the strong radius and well outside the release radius of the
|
|
2194
|
+
// origin it started on, so the boundary is armed.
|
|
2195
|
+
const effects = dragVo2(ctx, 6, 4, 4.08)
|
|
2196
|
+
expect(audio(lastProjectChange(effects), 'vo2').start).toBe(4)
|
|
2197
|
+
expect(of(effects, 'snapGuide')).toEqual(guide(4, 'strong'))
|
|
2198
|
+
})
|
|
2199
|
+
|
|
2200
|
+
it('still lets a butted bar start moving without a 44px fight', () => {
|
|
2201
|
+
// The origin is suppressed while the gesture sits on it, so the first small
|
|
2202
|
+
// drag moves the clip rather than being swallowed by the magnet.
|
|
2203
|
+
const project = butted(4, 9)
|
|
2204
|
+
const ctx = makeContext({ project, viewport: VIEWPORT, ...computeDerivedTiming(project) })
|
|
2205
|
+
const effects = dragVo2(ctx, 6, 4, 4.12, { viaEscape: false })
|
|
2206
|
+
expect(audio(lastProjectChange(effects), 'vo2').start).toBeCloseTo(4.12)
|
|
2207
|
+
})
|
|
2208
|
+
|
|
2209
|
+
it('a gapped neighbour was always fine, and still is', () => {
|
|
2210
|
+
const project = butted(4, 9)
|
|
2211
|
+
;(project.audio!.tracks as unknown as Array<Record<string, unknown>>)[1] = {
|
|
2212
|
+
id: 'vo2', src: 'vo2.wav', start: 5, end: 10, lane: 0,
|
|
2213
|
+
}
|
|
2214
|
+
const ctx = makeContext({ project, viewport: VIEWPORT, ...computeDerivedTiming(project) })
|
|
2215
|
+
expect(audio(lastProjectChange(dragVo2(ctx, 7, 5, 4.08)), 'vo2').start).toBe(4)
|
|
2216
|
+
})
|
|
2217
|
+
|
|
2218
|
+
it('a bar never snaps to its own edges — only to a real neighbour', () => {
|
|
2219
|
+
// vo2 alone in its lane: pulled clear of the origin there is nothing at 4
|
|
2220
|
+
// for it to catch, so it lands exactly where it was put.
|
|
2221
|
+
const project = {
|
|
2222
|
+
id: 'p',
|
|
2223
|
+
tracks: [{ id: 'trk-0', items: [
|
|
2224
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 30, inPoint: 0, outPoint: 30, sourceDuration: 60 },
|
|
2225
|
+
] }],
|
|
2226
|
+
audio: { tracks: [{ id: 'vo2', src: 'vo2.wav', start: 4, end: 9, lane: 0 }] },
|
|
2227
|
+
} as unknown as Project
|
|
2228
|
+
const ctx = makeContext({ project, viewport: VIEWPORT, ...computeDerivedTiming(project) })
|
|
2229
|
+
expect(audio(lastProjectChange(dragVo2(ctx, 6, 4, 4.08)), 'vo2').start).toBeCloseTo(4.08)
|
|
2230
|
+
})
|
|
2231
|
+
|
|
2232
|
+
it('applies to butted VIDEO clips too — the same bug, less often seen', () => {
|
|
2233
|
+
// c0 0-5 and c1 5-10 are butted on track 0. Drag c1 left toward 5.08.
|
|
2234
|
+
const d = new Driver(makeContext())
|
|
2235
|
+
d.down(C1_BODY.x, C1_BODY.y)
|
|
2236
|
+
const effects = d.move(C1_BODY.x - (5 - 5.08) * 100 - 300, C1_BODY.y)
|
|
2237
|
+
// Pulled well clear (3s left), then the assertion that matters is simply
|
|
2238
|
+
// that c0's end is a live magnet rather than having been filtered away.
|
|
2239
|
+
const boundaries = of(effects, 'snapGuide')
|
|
2240
|
+
expect(boundaries.length).toBeGreaterThanOrEqual(0)
|
|
2241
|
+
const d2 = new Driver(makeContext())
|
|
2242
|
+
d2.down(C1_BODY.x, C1_BODY.y)
|
|
2243
|
+
d2.move(C1_BODY.x - 300, C1_BODY.y) // break away
|
|
2244
|
+
const back = d2.move(C1_BODY.x + (5.08 - 5) * 100 - 0, C1_BODY.y) // return to 5.08
|
|
2245
|
+
expect(visual(lastProjectChange(back), 'c1').start).toBe(5)
|
|
2246
|
+
})
|
|
2247
|
+
})
|
|
2248
|
+
|
|
2249
|
+
// ── Playhead grab ──────────────────────────────────────────────────────────
|
|
2250
|
+
// Grabbing the full-height playhead line anywhere it is reachable scrubs, on
|
|
2251
|
+
// the same path the ruler uses. The fixture playhead sits at x = playheadTime
|
|
2252
|
+
// × 100 (100px/s, no scroll).
|
|
2253
|
+
describe('playhead grab', () => {
|
|
2254
|
+
it('grabs the playhead through a clip body and scrubs from the press', () => {
|
|
2255
|
+
// Playhead at x=700, inside c1's body (x 500–1000).
|
|
2256
|
+
const d = new Driver(makeContext({ playheadTime: 7 }))
|
|
2257
|
+
const eff = d.down(700, BASE_Y)
|
|
2258
|
+
const st = d.machine.state
|
|
2259
|
+
expect(st.kind).toBe('dragging')
|
|
2260
|
+
if (st.kind === 'dragging') expect(st.gesture).toBe('scrub')
|
|
2261
|
+
expect(of(eff, 'seek').length).toBeGreaterThan(0)
|
|
2262
|
+
expect(of(d.move(760, BASE_Y), 'seek').length).toBeGreaterThan(0)
|
|
2263
|
+
})
|
|
2264
|
+
|
|
2265
|
+
it('does not clear the selection when grabbing the playhead (D3)', () => {
|
|
2266
|
+
const d = new Driver(makeContext({ playheadTime: 7, selectedIds: ['c1'] }))
|
|
2267
|
+
expect(of(d.down(700, BASE_Y), 'select')).toEqual([])
|
|
2268
|
+
})
|
|
2269
|
+
|
|
2270
|
+
it('yields to a trim edge: a handle under the playhead still trims (D1)', () => {
|
|
2271
|
+
// Playhead at x=505, right on c1's in-edge handle.
|
|
2272
|
+
const d = new Driver(makeContext({ playheadTime: 5.05 }))
|
|
2273
|
+
d.down(C1_IN_EDGE.x, C1_IN_EDGE.y)
|
|
2274
|
+
expect(d.machine.state.kind).toBe('pressed') // not a scrub
|
|
2275
|
+
d.move(C1_IN_EDGE.x + 20, C1_IN_EDGE.y)
|
|
2276
|
+
const st = d.machine.state
|
|
2277
|
+
expect(st.kind).toBe('dragging')
|
|
2278
|
+
if (st.kind === 'dragging') expect(st.gesture).toBe('trim')
|
|
2279
|
+
})
|
|
2280
|
+
|
|
2281
|
+
it('an empty press away from the playhead is still a press, not a scrub', () => {
|
|
2282
|
+
const d = new Driver(makeContext({ playheadTime: 1 })) // playhead at x=100
|
|
2283
|
+
d.down(EMPTY.x, EMPTY.y) // x=700, far away
|
|
2284
|
+
expect(d.machine.state.kind).toBe('pressed')
|
|
2285
|
+
})
|
|
2286
|
+
|
|
2287
|
+
it('hovering the playhead shows the scrub cursor (D2)', () => {
|
|
2288
|
+
const d = new Driver(makeContext({ playheadTime: 7 }))
|
|
2289
|
+
expect(of(d.move(700, BASE_Y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'ew-resize' }])
|
|
2290
|
+
})
|
|
2291
|
+
|
|
2292
|
+
it('the ruler still clears the selection, unlike a playhead grab', () => {
|
|
2293
|
+
const d = new Driver(makeContext({ selectedIds: ['c1'] }))
|
|
2294
|
+
expect(of(d.down(400, RULER_Y), 'select')).toEqual([{ type: 'select', id: null, additive: false }])
|
|
2295
|
+
})
|
|
2296
|
+
})
|
|
2297
|
+
|
|
2298
|
+
// ── Multi-select move: the selection travels as one ────────────────────────
|
|
2299
|
+
describe('multi-select move', () => {
|
|
2300
|
+
it('shifts every selected item by one delta, preserving the gaps', () => {
|
|
2301
|
+
const d = new Driver(makeContext({ selectedIds: ['c0', 'c1'] }))
|
|
2302
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
2303
|
+
const eff = d.move(C0_BODY.x + 120, C0_BODY.y)
|
|
2304
|
+
// A group move never re-selects — the selection is preserved as-is.
|
|
2305
|
+
expect(of(eff, 'selectMany')).toEqual([])
|
|
2306
|
+
const proj = lastProjectChange(eff)
|
|
2307
|
+
const c0 = visual(proj, 'c0'), c1 = visual(proj, 'c1')
|
|
2308
|
+
const delta = c0.start - 0
|
|
2309
|
+
expect(delta).toBeGreaterThan(0)
|
|
2310
|
+
expect(c1.start - 5).toBeCloseTo(delta) // c1 shifted by the SAME delta
|
|
2311
|
+
expect(c0.end - c0.start).toBeCloseTo(5) // durations preserved
|
|
2312
|
+
expect(c1.end - c1.start).toBeCloseTo(5)
|
|
2313
|
+
})
|
|
2314
|
+
|
|
2315
|
+
it('a drag on an item NOT in the selection selects just it and moves it alone', () => {
|
|
2316
|
+
const d = new Driver(makeContext({ selectedIds: ['c1'] }))
|
|
2317
|
+
d.down(C0_BODY.x, C0_BODY.y) // press c0, which is not selected
|
|
2318
|
+
const eff = d.move(C0_BODY.x + 120, C0_BODY.y)
|
|
2319
|
+
expect(of(eff, 'selectMany')).toEqual([{ type: 'selectMany', ids: ['c0'], additive: false }])
|
|
2320
|
+
const proj = lastProjectChange(eff)
|
|
2321
|
+
expect(visual(proj, 'c0').start).toBeGreaterThan(0) // c0 moved
|
|
2322
|
+
expect(visual(proj, 'c1').start).toBe(5) // c1 untouched
|
|
2323
|
+
})
|
|
2324
|
+
|
|
2325
|
+
it('clamps the group as a body so no member leaves the timeline', () => {
|
|
2326
|
+
// c0 already starts at 0, so dragging the pair hard left cannot move it.
|
|
2327
|
+
const d = new Driver(makeContext({ selectedIds: ['c0', 'c1'] }))
|
|
2328
|
+
d.down(C0_BODY.x, C0_BODY.y)
|
|
2329
|
+
const changes = of(d.move(C0_BODY.x - 300, C0_BODY.y), 'projectChange')
|
|
2330
|
+
if (changes.length) {
|
|
2331
|
+
const proj = changes[changes.length - 1].project
|
|
2332
|
+
expect(visual(proj, 'c0').start).toBe(0)
|
|
2333
|
+
expect(visual(proj, 'c1').start).toBe(5)
|
|
2334
|
+
}
|
|
2335
|
+
})
|
|
2336
|
+
})
|
|
2337
|
+
|
|
2338
|
+
// ── Captions ─────────────────────────────────────────────────────────────
|
|
2339
|
+
//
|
|
2340
|
+
// Caption blocks live in the canvas timeline now, and behave exactly like
|
|
2341
|
+
// overlay clips: click to select, drag the body to re-time, drag an edge to
|
|
2342
|
+
// trim, group-move with whatever else is selected. Their ids share the ONE
|
|
2343
|
+
// `selectedIds` array with clips and audio, which is what makes a mixed
|
|
2344
|
+
// clip+caption drag land as a single undo.
|
|
2345
|
+
//
|
|
2346
|
+
// Its own fixture — one clip and a caption band, nothing else — so the base
|
|
2347
|
+
// project's row geometry above stays exactly as those tests assume:
|
|
2348
|
+
//
|
|
2349
|
+
// band caption s0 1s–2s (with word timings), s1 3.44s–4.5s, one id-less
|
|
2350
|
+
// row track 0 — c0 0s–10s
|
|
2351
|
+
//
|
|
2352
|
+
// s1's start is 3.44 on purpose: at 30fps that is frame 103.2, the fractional
|
|
2353
|
+
// part below 0.5 that makes the naive seek-to-`start` land in the PREVIOUS
|
|
2354
|
+
// segment. See the click-seek suite.
|
|
2355
|
+
|
|
2356
|
+
const CAPTION_FPS = 30
|
|
2357
|
+
|
|
2358
|
+
function captionProject(): Project {
|
|
2359
|
+
return {
|
|
2360
|
+
id: 'p',
|
|
2361
|
+
tracks: [{
|
|
2362
|
+
id: 'trk-0',
|
|
2363
|
+
items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
|
|
2364
|
+
}],
|
|
2365
|
+
captions: {
|
|
2366
|
+
style: 'pop',
|
|
2367
|
+
segments: [
|
|
2368
|
+
{
|
|
2369
|
+
id: 's0',
|
|
2370
|
+
text: 'hello there',
|
|
2371
|
+
start: 1,
|
|
2372
|
+
end: 2,
|
|
2373
|
+
words: [
|
|
2374
|
+
{ word: 'hello', start: 1, end: 1.6 },
|
|
2375
|
+
{ word: 'there', start: 1.6, end: 2 },
|
|
2376
|
+
],
|
|
2377
|
+
},
|
|
2378
|
+
{ id: 's1', text: 'second', start: 3.44, end: 4.5 },
|
|
2379
|
+
// No id yet — `backfillCaptionIds` has not run. Never hittable.
|
|
2380
|
+
{ text: 'unminted', start: 7, end: 8 },
|
|
2381
|
+
],
|
|
2382
|
+
},
|
|
2383
|
+
} as unknown as Project
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
const CAPTION_LAYOUT = computeTimelineLayout(captionProject())
|
|
2387
|
+
const CAPTION_Y = Math.round(CAPTION_LAYOUT.captions![0].y + CAPTION_LAYOUT.captions![0].height / 2)
|
|
2388
|
+
|
|
2389
|
+
const S0_BODY = { x: 150, y: CAPTION_Y }
|
|
2390
|
+
const S0_IN_EDGE = { x: 102, y: CAPTION_Y }
|
|
2391
|
+
const S0_OUT_EDGE = { x: 198, y: CAPTION_Y }
|
|
2392
|
+
const S1_BODY = { x: 400, y: CAPTION_Y }
|
|
2393
|
+
/** s1's in handle (s1 spans x 344–450). */
|
|
2394
|
+
const S1_IN_EDGE = { x: 346, y: CAPTION_Y }
|
|
2395
|
+
/** Over the id-less segment (x 700–800), which is band background. */
|
|
2396
|
+
const UNMINTED = { x: 750, y: CAPTION_Y }
|
|
2397
|
+
/** The gap between s0 and s1. */
|
|
2398
|
+
const CAPTION_GAP = { x: 260, y: CAPTION_Y }
|
|
2399
|
+
|
|
2400
|
+
function captionCtx(overrides: Partial<PointerContext> = {}): PointerContext {
|
|
2401
|
+
return makeContext({ project: captionProject(), fps: CAPTION_FPS, ...overrides })
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
function caption(project: Project, id: string) {
|
|
2405
|
+
return (project.captions?.segments ?? []).find(s => s.id === id)!
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
describe('caption gestures — resolution and affordances', () => {
|
|
2409
|
+
const ctx = captionCtx()
|
|
2410
|
+
const bodyHit = hitTest(S0_BODY, ctx.layout, VIEWPORT)
|
|
2411
|
+
const edgeHit = hitTest(S0_OUT_EDGE, ctx.layout, VIEWPORT)
|
|
2412
|
+
|
|
2413
|
+
it('a body-drag re-times and an edge-drag trims', () => {
|
|
2414
|
+
expect(resolveGesture(bodyHit, mods())).toBe('caption-move')
|
|
2415
|
+
expect(resolveGesture(edgeHit, mods())).toBe('caption-trim')
|
|
2416
|
+
})
|
|
2417
|
+
|
|
2418
|
+
it('has no roll/slip/slide, so modifiers fall through as they do for audio', () => {
|
|
2419
|
+
expect(resolveGesture(bodyHit, mods({ alt: true }))).toBe('caption-move')
|
|
2420
|
+
expect(resolveGesture(bodyHit, mods({ meta: true }))).toBe('caption-move')
|
|
2421
|
+
expect(resolveGesture(edgeHit, mods({ alt: true }))).toBe('caption-trim')
|
|
2422
|
+
})
|
|
2423
|
+
|
|
2424
|
+
it('shows the resize cursor on an edge and the grab cursor on a body', () => {
|
|
2425
|
+
expect(cursorForHit(edgeHit)).toBe('ew-resize')
|
|
2426
|
+
expect(cursorForHit(bodyHit)).toBe('grab')
|
|
2427
|
+
})
|
|
2428
|
+
|
|
2429
|
+
it('switches to the gesture cursor once the drag starts', () => {
|
|
2430
|
+
const move = new Driver(captionCtx())
|
|
2431
|
+
move.down(S0_BODY.x, S0_BODY.y)
|
|
2432
|
+
expect(of(move.move(S0_BODY.x + 50, S0_BODY.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'grabbing' }])
|
|
2433
|
+
|
|
2434
|
+
// A trim PRESS already set `ew-resize` from the hit, and the gesture's own
|
|
2435
|
+
// cursor is the same one — so the machine says nothing more, because it
|
|
2436
|
+
// only speaks up when the cursor actually changes.
|
|
2437
|
+
const trim = new Driver(captionCtx())
|
|
2438
|
+
expect(of(trim.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'ew-resize' }])
|
|
2439
|
+
expect(of(trim.move(S0_OUT_EDGE.x + 50, S0_OUT_EDGE.y), 'cursor')).toEqual([])
|
|
2440
|
+
expect(trim.machine.state.cursor).toBe('ew-resize')
|
|
2441
|
+
})
|
|
2442
|
+
})
|
|
2443
|
+
|
|
2444
|
+
describe('caption click — select, and seek half a frame IN', () => {
|
|
2445
|
+
it('selects on release and seeks to start + half a frame', () => {
|
|
2446
|
+
const d = new Driver(captionCtx())
|
|
2447
|
+
d.down(S1_BODY.x, S1_BODY.y)
|
|
2448
|
+
const effects = d.up(S1_BODY.x, S1_BODY.y)
|
|
2449
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: 's1', additive: false }])
|
|
2450
|
+
expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 3.44 + 0.5 / CAPTION_FPS }])
|
|
2451
|
+
})
|
|
2452
|
+
|
|
2453
|
+
it('lands inside the segment once the preview snaps the clock to the frame grid', () => {
|
|
2454
|
+
// The regression this exists for. CaptionPreview runs
|
|
2455
|
+
// `t = Math.round(currentTime * fps) / fps` before the templates' own
|
|
2456
|
+
// `t >= start && t < end` test, and 3.44 × 30 = 103.2 rounds DOWN.
|
|
2457
|
+
const snapToGrid = (t: number) => Math.round(t * CAPTION_FPS) / CAPTION_FPS
|
|
2458
|
+
expect(snapToGrid(3.44)).toBeLessThan(3.44) // seeking to `start` misses
|
|
2459
|
+
const d = new Driver(captionCtx())
|
|
2460
|
+
d.down(S1_BODY.x, S1_BODY.y)
|
|
2461
|
+
const seeked = of(d.up(S1_BODY.x, S1_BODY.y), 'seek')[0].time
|
|
2462
|
+
expect(snapToGrid(seeked)).toBeGreaterThanOrEqual(3.44) // half a frame in does not
|
|
2463
|
+
expect(snapToGrid(seeked)).toBeLessThan(4.5)
|
|
2464
|
+
})
|
|
2465
|
+
|
|
2466
|
+
it('seeks to the segment start, NOT to where the pointer clicked', () => {
|
|
2467
|
+
// x=400 is t=4, well inside s1 — a visual clip would seek there.
|
|
2468
|
+
const d = new Driver(captionCtx())
|
|
2469
|
+
d.down(S1_BODY.x, S1_BODY.y)
|
|
2470
|
+
expect(of(d.up(S1_BODY.x, S1_BODY.y), 'seek')[0].time).not.toBeCloseTo(4)
|
|
2471
|
+
})
|
|
2472
|
+
|
|
2473
|
+
it('does not seek when the caption was already selected', () => {
|
|
2474
|
+
const d = new Driver(captionCtx({ selectedIds: ['s1'] }))
|
|
2475
|
+
d.down(S1_BODY.x, S1_BODY.y)
|
|
2476
|
+
const effects = d.up(S1_BODY.x, S1_BODY.y)
|
|
2477
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: 's1', additive: false }])
|
|
2478
|
+
expect(of(effects, 'seek')).toEqual([])
|
|
2479
|
+
})
|
|
2480
|
+
|
|
2481
|
+
it('does not seek on an additive click', () => {
|
|
2482
|
+
const d = new Driver(captionCtx())
|
|
2483
|
+
d.down(S1_BODY.x, S1_BODY.y, mods({ shift: true }))
|
|
2484
|
+
const effects = d.up(S1_BODY.x, S1_BODY.y, mods({ shift: true }))
|
|
2485
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: 's1', additive: true }])
|
|
2486
|
+
expect(of(effects, 'seek')).toEqual([])
|
|
2487
|
+
})
|
|
2488
|
+
|
|
2489
|
+
it('selects from a press on a trim handle that never became a drag', () => {
|
|
2490
|
+
const d = new Driver(captionCtx())
|
|
2491
|
+
d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2492
|
+
const effects = d.up(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2493
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: 's0', additive: false }])
|
|
2494
|
+
expect(of(effects, 'commit')).toEqual([])
|
|
2495
|
+
})
|
|
2496
|
+
|
|
2497
|
+
it('treats a gap in the band as background: clears the selection and seeks there', () => {
|
|
2498
|
+
const d = new Driver(captionCtx({ selectedIds: ['s0'] }))
|
|
2499
|
+
d.down(CAPTION_GAP.x, CAPTION_GAP.y)
|
|
2500
|
+
const effects = d.up(CAPTION_GAP.x, CAPTION_GAP.y)
|
|
2501
|
+
expect(of(effects, 'select')).toEqual([{ type: 'select', id: null, additive: false }])
|
|
2502
|
+
expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 2.6 }])
|
|
2503
|
+
})
|
|
2504
|
+
|
|
2505
|
+
it('never selects a segment that has no id yet', () => {
|
|
2506
|
+
const d = new Driver(captionCtx())
|
|
2507
|
+
d.down(UNMINTED.x, UNMINTED.y)
|
|
2508
|
+
expect(of(d.up(UNMINTED.x, UNMINTED.y), 'select')).toEqual([{ type: 'select', id: null, additive: false }])
|
|
2509
|
+
})
|
|
2510
|
+
})
|
|
2511
|
+
|
|
2512
|
+
describe('caption body drag — move', () => {
|
|
2513
|
+
it('MOVES a caption that was not selected when the drag began', () => {
|
|
2514
|
+
// The stale-selection trap. `selectMany` selects the grabbed caption, but
|
|
2515
|
+
// the host has not echoed it back into `ctx.selectedIds` yet, so a move
|
|
2516
|
+
// that keyed off `ctx.selectedIds` alone would shift nothing at all and
|
|
2517
|
+
// the block would sit still under the pointer.
|
|
2518
|
+
const d = new Driver(captionCtx())
|
|
2519
|
+
d.down(S0_BODY.x, S0_BODY.y)
|
|
2520
|
+
const eff = d.move(S0_BODY.x + 100, S0_BODY.y)
|
|
2521
|
+
expect(of(eff, 'selectMany')).toEqual([{ type: 'selectMany', ids: ['s0'], additive: false }])
|
|
2522
|
+
const moved = caption(lastProjectChange(eff), 's0')
|
|
2523
|
+
expect(moved.start).toBeCloseTo(2)
|
|
2524
|
+
expect(moved.end).toBeCloseTo(3)
|
|
2525
|
+
})
|
|
2526
|
+
|
|
2527
|
+
it('moves ONLY the grabbed caption when something else holds the selection', () => {
|
|
2528
|
+
const d = new Driver(captionCtx({ selectedIds: ['c0'] }))
|
|
2529
|
+
d.down(S0_BODY.x, S0_BODY.y)
|
|
2530
|
+
const proj = lastProjectChange(d.move(S0_BODY.x + 100, S0_BODY.y))
|
|
2531
|
+
expect(caption(proj, 's0').start).toBeCloseTo(2)
|
|
2532
|
+
expect(visual(proj, 'c0').start).toBe(0) // the old selection stayed put
|
|
2533
|
+
})
|
|
2534
|
+
|
|
2535
|
+
it('carries every word timing with the segment', () => {
|
|
2536
|
+
const d = new Driver(captionCtx())
|
|
2537
|
+
d.down(S0_BODY.x, S0_BODY.y)
|
|
2538
|
+
const moved = caption(lastProjectChange(d.move(S0_BODY.x + 100, S0_BODY.y)), 's0')
|
|
2539
|
+
expect(moved.words).toEqual([
|
|
2540
|
+
{ word: 'hello', start: 2, end: 2.6 },
|
|
2541
|
+
{ word: 'there', start: 2.6, end: 3 },
|
|
2542
|
+
])
|
|
2543
|
+
expect(moved.text).toBe('hello there')
|
|
2544
|
+
})
|
|
2545
|
+
|
|
2546
|
+
it('emits one projectChange per move and exactly ONE commit at release', () => {
|
|
2547
|
+
const d = new Driver(captionCtx({ selectedIds: ['s0'] }))
|
|
2548
|
+
d.down(S0_BODY.x, S0_BODY.y)
|
|
2549
|
+
expect(of(d.move(S0_BODY.x + 50, S0_BODY.y), 'projectChange')).toHaveLength(1)
|
|
2550
|
+
expect(of(d.move(S0_BODY.x + 80, S0_BODY.y), 'projectChange')).toHaveLength(1)
|
|
2551
|
+
expect(of(d.move(S0_BODY.x + 100, S0_BODY.y), 'projectChange')).toHaveLength(1)
|
|
2552
|
+
const committed = of(d.up(S0_BODY.x + 100, S0_BODY.y), 'commit')
|
|
2553
|
+
expect(committed).toHaveLength(1)
|
|
2554
|
+
expect(caption(committed[0].project, 's0').start).toBeCloseTo(2)
|
|
2555
|
+
})
|
|
2556
|
+
|
|
2557
|
+
it('co-moves a whole caption selection under one delta', () => {
|
|
2558
|
+
const d = new Driver(captionCtx({ selectedIds: ['s0', 's1'] }))
|
|
2559
|
+
d.down(S0_BODY.x, S0_BODY.y)
|
|
2560
|
+
const eff = d.move(S0_BODY.x + 100, S0_BODY.y)
|
|
2561
|
+
// A group move never re-selects.
|
|
2562
|
+
expect(of(eff, 'selectMany')).toEqual([])
|
|
2563
|
+
const proj = lastProjectChange(eff)
|
|
2564
|
+
expect(caption(proj, 's0')).toMatchObject({ start: 2, end: 3 })
|
|
2565
|
+
expect(caption(proj, 's1').start).toBeCloseTo(4.44)
|
|
2566
|
+
expect(caption(proj, 's1').end).toBeCloseTo(5.5)
|
|
2567
|
+
expect(of(d.up(S0_BODY.x + 100, S0_BODY.y), 'commit')).toHaveLength(1)
|
|
2568
|
+
})
|
|
2569
|
+
|
|
2570
|
+
it('co-moves a MIXED clip+caption selection, and commits it once', () => {
|
|
2571
|
+
// The point of putting caption ids in the same `selectedIds` array: one
|
|
2572
|
+
// gesture, one `projectChange` stream, one undo entry.
|
|
2573
|
+
const d = new Driver(captionCtx({ selectedIds: ['c0', 's0'] }))
|
|
2574
|
+
d.down(S0_BODY.x, S0_BODY.y)
|
|
2575
|
+
const proj = lastProjectChange(d.move(S0_BODY.x + 100, S0_BODY.y))
|
|
2576
|
+
expect(visual(proj, 'c0')).toMatchObject({ start: 1, end: 11 })
|
|
2577
|
+
expect(caption(proj, 's0')).toMatchObject({ start: 2, end: 3 })
|
|
2578
|
+
const committed = of(d.up(S0_BODY.x + 100, S0_BODY.y), 'commit')
|
|
2579
|
+
expect(committed).toHaveLength(1)
|
|
2580
|
+
expect(caption(committed[0].project, 's0').start).toBeCloseTo(2)
|
|
2581
|
+
expect(visual(committed[0].project, 'c0').start).toBeCloseTo(1)
|
|
2582
|
+
})
|
|
2583
|
+
|
|
2584
|
+
it('clamps the group as a body so no member leaves the timeline', () => {
|
|
2585
|
+
// Single caption, no other captions in the project — isolates the
|
|
2586
|
+
// timeline-edge clamp from the caption-neighbour clamp (its own describe
|
|
2587
|
+
// block below), which would otherwise stop s0 at s1's start (3.44) long
|
|
2588
|
+
// before it ever reached `ctx.totalDuration`.
|
|
2589
|
+
const soloProject: Project = {
|
|
2590
|
+
...captionProject(),
|
|
2591
|
+
captions: { style: 'pop', segments: [{ id: 's0', text: 'hello there', start: 1, end: 2 }] },
|
|
2592
|
+
} as unknown as Project
|
|
2593
|
+
const ctx = captionCtx({ project: soloProject, selectedIds: ['s0'] })
|
|
2594
|
+
const d = new Driver(ctx)
|
|
2595
|
+
d.down(S0_BODY.x, S0_BODY.y)
|
|
2596
|
+
expect(caption(lastProjectChange(d.move(-9999, S0_BODY.y)), 's0').start).toBe(0)
|
|
2597
|
+
expect(caption(lastProjectChange(d.move(99999, S0_BODY.y)), 's0').end).toBeCloseTo(ctx.totalDuration)
|
|
2598
|
+
})
|
|
2599
|
+
|
|
2600
|
+
it('commits nothing when the drag ends back where it started', () => {
|
|
2601
|
+
const d = new Driver(captionCtx({ selectedIds: ['s0'] }))
|
|
2602
|
+
d.down(S0_BODY.x, S0_BODY.y)
|
|
2603
|
+
d.move(S0_BODY.x + 100, S0_BODY.y)
|
|
2604
|
+
d.move(S0_BODY.x, S0_BODY.y)
|
|
2605
|
+
expect(of(d.up(S0_BODY.x, S0_BODY.y), 'commit')).toEqual([])
|
|
2606
|
+
})
|
|
2607
|
+
})
|
|
2608
|
+
|
|
2609
|
+
describe('caption edge drag — trim', () => {
|
|
2610
|
+
it('moves ONLY the dragged out edge', () => {
|
|
2611
|
+
const d = new Driver(captionCtx())
|
|
2612
|
+
d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2613
|
+
// The edge lands at its own time plus the pointer's travel: the press was
|
|
2614
|
+
// at x=198 (inside the 6px handle), so 250 is 0.52s further on.
|
|
2615
|
+
const trimmed = caption(lastProjectChange(d.move(250, S0_OUT_EDGE.y)), 's0')
|
|
2616
|
+
expect(trimmed.end).toBeCloseTo(2.52)
|
|
2617
|
+
expect(trimmed.start).toBe(1)
|
|
2618
|
+
})
|
|
2619
|
+
|
|
2620
|
+
it('moves ONLY the dragged in edge', () => {
|
|
2621
|
+
const d = new Driver(captionCtx())
|
|
2622
|
+
d.down(S0_IN_EDGE.x, S0_IN_EDGE.y)
|
|
2623
|
+
const trimmed = caption(lastProjectChange(d.move(150, S0_IN_EDGE.y)), 's0')
|
|
2624
|
+
expect(trimmed.start).toBeCloseTo(1.48)
|
|
2625
|
+
expect(trimmed.end).toBe(2)
|
|
2626
|
+
})
|
|
2627
|
+
|
|
2628
|
+
it('never touches text or words — a retime is not a respread', () => {
|
|
2629
|
+
const before = captionProject()
|
|
2630
|
+
const d = new Driver(makeContext({ project: before, fps: CAPTION_FPS }))
|
|
2631
|
+
d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2632
|
+
const trimmed = caption(lastProjectChange(d.move(250, S0_OUT_EDGE.y)), 's0')
|
|
2633
|
+
expect(trimmed.text).toBe('hello there')
|
|
2634
|
+
// The same array object, not merely the same values: nothing respread it.
|
|
2635
|
+
expect(trimmed.words).toBe(caption(before, 's0').words)
|
|
2636
|
+
})
|
|
2637
|
+
|
|
2638
|
+
it('leaves every OTHER segment alone', () => {
|
|
2639
|
+
const d = new Driver(captionCtx())
|
|
2640
|
+
d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2641
|
+
const proj = lastProjectChange(d.move(250, S0_OUT_EDGE.y))
|
|
2642
|
+
expect(caption(proj, 's1')).toMatchObject({ start: 3.44, end: 4.5 })
|
|
2643
|
+
})
|
|
2644
|
+
|
|
2645
|
+
it('never trims below the minimum duration, from either edge', () => {
|
|
2646
|
+
// The floor the click-seek leans on: no segment is ever under one frame.
|
|
2647
|
+
expect(CAPTION_MIN_DURATION_S).toBeGreaterThan(1 / CAPTION_FPS)
|
|
2648
|
+
|
|
2649
|
+
const out = new Driver(captionCtx())
|
|
2650
|
+
out.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2651
|
+
expect(caption(lastProjectChange(out.move(20, S0_OUT_EDGE.y)), 's0').end)
|
|
2652
|
+
.toBeCloseTo(1 + CAPTION_MIN_DURATION_S)
|
|
2653
|
+
|
|
2654
|
+
const inn = new Driver(captionCtx())
|
|
2655
|
+
inn.down(S0_IN_EDGE.x, S0_IN_EDGE.y)
|
|
2656
|
+
expect(caption(lastProjectChange(inn.move(600, S0_IN_EDGE.y)), 's0').start)
|
|
2657
|
+
.toBeCloseTo(2 - CAPTION_MIN_DURATION_S)
|
|
2658
|
+
})
|
|
2659
|
+
|
|
2660
|
+
it('does not trim a multi-selection — v1 trims one segment', () => {
|
|
2661
|
+
const d = new Driver(captionCtx({ selectedIds: ['s0', 's1'] }))
|
|
2662
|
+
d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2663
|
+
const proj = lastProjectChange(d.move(250, S0_OUT_EDGE.y))
|
|
2664
|
+
expect(caption(proj, 's0').end).toBeCloseTo(2.52)
|
|
2665
|
+
expect(caption(proj, 's1')).toMatchObject({ start: 3.44, end: 4.5 })
|
|
2666
|
+
})
|
|
2667
|
+
|
|
2668
|
+
it('commits once, and only what actually changed', () => {
|
|
2669
|
+
const d = new Driver(captionCtx())
|
|
2670
|
+
d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2671
|
+
d.move(250, S0_OUT_EDGE.y)
|
|
2672
|
+
const committed = of(d.up(250, S0_OUT_EDGE.y), 'commit')
|
|
2673
|
+
expect(committed).toHaveLength(1)
|
|
2674
|
+
expect(caption(committed[0].project, 's0')).toMatchObject({ start: 1, end: 2.52 })
|
|
2675
|
+
})
|
|
2676
|
+
|
|
2677
|
+
it('commits nothing when the edge ends back where it started', () => {
|
|
2678
|
+
const d = new Driver(captionCtx())
|
|
2679
|
+
d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2680
|
+
d.move(250, S0_OUT_EDGE.y)
|
|
2681
|
+
d.move(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2682
|
+
expect(of(d.up(S0_OUT_EDGE.x, S0_OUT_EDGE.y), 'commit')).toEqual([])
|
|
2683
|
+
})
|
|
2684
|
+
|
|
2685
|
+
it("stops an end-edge trim at the next caption's start — captions may not overlap", () => {
|
|
2686
|
+
// Dragged far past s1 (x=2000, t=20). Without the neighbour clamp this
|
|
2687
|
+
// would land at `Math.max(ctx.totalDuration, seg.end)` = 15; with it, s1
|
|
2688
|
+
// starts at 3.44 and that is the real ceiling.
|
|
2689
|
+
const d = new Driver(captionCtx())
|
|
2690
|
+
d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
|
|
2691
|
+
const trimmed = caption(lastProjectChange(d.move(2000, S0_OUT_EDGE.y)), 's0')
|
|
2692
|
+
expect(trimmed.end).toBeCloseTo(3.44)
|
|
2693
|
+
expect(trimmed.start).toBe(1)
|
|
2694
|
+
})
|
|
2695
|
+
|
|
2696
|
+
it("stops a start-edge trim at the previous caption's end — captions may not overlap", () => {
|
|
2697
|
+
// Dragged far past s0 (x=0, t=0). Without the neighbour clamp this would
|
|
2698
|
+
// land at 0 (`lo`'s flat floor); with it, s0 ends at 2 and that is the
|
|
2699
|
+
// real floor.
|
|
2700
|
+
const d = new Driver(captionCtx())
|
|
2701
|
+
d.down(S1_IN_EDGE.x, S1_IN_EDGE.y)
|
|
2702
|
+
const trimmed = caption(lastProjectChange(d.move(0, S1_IN_EDGE.y)), 's1')
|
|
2703
|
+
expect(trimmed.start).toBeCloseTo(2)
|
|
2704
|
+
expect(trimmed.end).toBeCloseTo(4.5)
|
|
2705
|
+
})
|
|
2706
|
+
|
|
2707
|
+
it('a drag clamped to zero movement by a butted neighbour still emits no commit', () => {
|
|
2708
|
+
// Two captions already touching (a's end === b's start === 2, the
|
|
2709
|
+
// `resolveRow` tie-break case): a's out edge has no legal room to move
|
|
2710
|
+
// right at all, so `hi` equals its own current value from the first
|
|
2711
|
+
// frame — the clamp lands it back on `initTime` every time.
|
|
2712
|
+
const butted: Project = {
|
|
2713
|
+
id: 'p',
|
|
2714
|
+
tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }] }],
|
|
2715
|
+
captions: {
|
|
2716
|
+
style: 'pop',
|
|
2717
|
+
segments: [
|
|
2718
|
+
{ id: 'a', text: 'first', start: 1, end: 2 },
|
|
2719
|
+
{ id: 'b', text: 'second', start: 2, end: 3 },
|
|
2720
|
+
],
|
|
2721
|
+
},
|
|
2722
|
+
} as unknown as Project
|
|
2723
|
+
const d = new Driver(captionCtx({ project: butted }))
|
|
2724
|
+
// a's out edge sits at x=198 (200 - 2, same offset every other edge point
|
|
2725
|
+
// in this file uses), safely inside a and not on the shared boundary.
|
|
2726
|
+
d.down(198, CAPTION_Y)
|
|
2727
|
+
const move = d.move(280, CAPTION_Y)
|
|
2728
|
+
expect(of(move, 'projectChange')).toEqual([])
|
|
2729
|
+
expect(caption(butted, 'a')).toMatchObject({ start: 1, end: 2 })
|
|
2730
|
+
expect(of(d.up(280, CAPTION_Y), 'commit')).toEqual([])
|
|
2731
|
+
})
|
|
2732
|
+
})
|
|
2733
|
+
|
|
2734
|
+
describe('caption playhead grab', () => {
|
|
2735
|
+
it('a grab over a caption BODY scrubs instead of selecting', () => {
|
|
2736
|
+
// Playhead at x=150, inside s0's body.
|
|
2737
|
+
const d = new Driver(captionCtx({ playheadTime: 1.5 }))
|
|
2738
|
+
const eff = d.down(150, CAPTION_Y)
|
|
2739
|
+
const st = d.machine.state
|
|
2740
|
+
expect(st.kind).toBe('dragging')
|
|
2741
|
+
if (st.kind === 'dragging') expect(st.gesture).toBe('scrub')
|
|
2742
|
+
expect(of(eff, 'seek').length).toBeGreaterThan(0)
|
|
2743
|
+
expect(of(eff, 'select')).toEqual([])
|
|
2744
|
+
})
|
|
2745
|
+
|
|
2746
|
+
it('a grab over a caption EDGE trims — the precise target wins (D1)', () => {
|
|
2747
|
+
// Playhead at x=102, right on s0's in handle.
|
|
2748
|
+
const d = new Driver(captionCtx({ playheadTime: 1.02 }))
|
|
2749
|
+
d.down(S0_IN_EDGE.x, S0_IN_EDGE.y)
|
|
2750
|
+
expect(d.machine.state.kind).toBe('pressed') // not a scrub
|
|
2751
|
+
d.move(S0_IN_EDGE.x + 30, S0_IN_EDGE.y)
|
|
2752
|
+
const st = d.machine.state
|
|
2753
|
+
expect(st.kind).toBe('dragging')
|
|
2754
|
+
if (st.kind === 'dragging') expect(st.gesture).toBe('caption-trim')
|
|
2755
|
+
})
|
|
2756
|
+
})
|
|
2757
|
+
|
|
2758
|
+
describe('caption double-click', () => {
|
|
2759
|
+
it('opens the caption for editing rather than an inspector', () => {
|
|
2760
|
+
const d = new Driver(captionCtx())
|
|
2761
|
+
expect(d.doubleClick(S0_BODY.x, S0_BODY.y)).toEqual([{ type: 'editCaption', id: 's0' }])
|
|
2762
|
+
})
|
|
2763
|
+
|
|
2764
|
+
it('does the same from a caption edge', () => {
|
|
2765
|
+
const d = new Driver(captionCtx())
|
|
2766
|
+
expect(d.doubleClick(S0_OUT_EDGE.x, S0_OUT_EDGE.y)).toEqual([{ type: 'editCaption', id: 's0' }])
|
|
2767
|
+
})
|
|
2768
|
+
|
|
2769
|
+
it('does nothing in the gaps between blocks', () => {
|
|
2770
|
+
const d = new Driver(captionCtx())
|
|
2771
|
+
expect(d.doubleClick(CAPTION_GAP.x, CAPTION_GAP.y)).toEqual([])
|
|
2772
|
+
expect(d.doubleClick(UNMINTED.x, UNMINTED.y)).toEqual([])
|
|
2773
|
+
})
|
|
2774
|
+
})
|
|
2775
|
+
|
|
2776
|
+
// ── Caption trim at the timeline's own edges ──────────────────────────────
|
|
2777
|
+
//
|
|
2778
|
+
// The trim has two invariants that can fight each other: the edge stays inside
|
|
2779
|
+
// [0, totalDuration], and the segment stays at least `CAPTION_MIN_DURATION_S`
|
|
2780
|
+
// long. They only collide on a segment ALREADY shorter than the floor sitting
|
|
2781
|
+
// flush against a boundary — which Whisper produces routinely (a one-syllable
|
|
2782
|
+
// interjection) and which a caption move can push against `totalDuration`.
|
|
2783
|
+
//
|
|
2784
|
+
// Zoomed to 1000px/s: these blocks are 50px wide, so their handles are
|
|
2785
|
+
// separately grabbable. At the fixture's usual 100px/s a 5px block would
|
|
2786
|
+
// resolve every point to its OUT handle and the in-edge probes would miss.
|
|
2787
|
+
|
|
2788
|
+
const TINY_VIEWPORT: Viewport = { pxPerSecond: 1000, scrollSeconds: 0, widthPx: 1000 }
|
|
2789
|
+
|
|
2790
|
+
function tinyCaptionProject(): Project {
|
|
2791
|
+
return {
|
|
2792
|
+
id: 'p',
|
|
2793
|
+
tracks: [{
|
|
2794
|
+
id: 'trk-0',
|
|
2795
|
+
items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
|
|
2796
|
+
}],
|
|
2797
|
+
captions: {
|
|
2798
|
+
style: 'pop',
|
|
2799
|
+
segments: [
|
|
2800
|
+
// Shorter than the floor, flush against t=0.
|
|
2801
|
+
{ id: 'head', text: 'uh', start: 0, end: 0.05 },
|
|
2802
|
+
// Shorter than the floor, flush against totalDuration (10 + 5 = 15).
|
|
2803
|
+
{ id: 'tail', text: 'hm', start: 14.95, end: 15 },
|
|
2804
|
+
// Shorter than the floor, but nowhere near either boundary.
|
|
2805
|
+
{ id: 'middle', text: 'so', start: 5, end: 5.05 },
|
|
2806
|
+
],
|
|
2807
|
+
},
|
|
2808
|
+
} as unknown as Project
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
const TINY_LAYOUT = computeTimelineLayout(tinyCaptionProject())
|
|
2812
|
+
const TINY_Y = Math.round(TINY_LAYOUT.captions![0].y + TINY_LAYOUT.captions![0].height / 2)
|
|
2813
|
+
|
|
2814
|
+
function tinyCtx(overrides: Partial<PointerContext> = {}): PointerContext {
|
|
2815
|
+
return makeContext({ project: tinyCaptionProject(), fps: CAPTION_FPS, viewport: TINY_VIEWPORT, ...overrides })
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
describe('caption trim — a sub-floor segment pinned at a timeline edge', () => {
|
|
2819
|
+
it('the fixture is the collision case: sub-floor segments, flush at both ends', () => {
|
|
2820
|
+
const ctx = tinyCtx()
|
|
2821
|
+
expect(ctx.totalDuration).toBe(15)
|
|
2822
|
+
for (const id of ['head', 'tail', 'middle']) {
|
|
2823
|
+
const seg = caption(ctx.project, id)
|
|
2824
|
+
expect(seg.end - seg.start).toBeLessThan(CAPTION_MIN_DURATION_S)
|
|
2825
|
+
}
|
|
2826
|
+
})
|
|
2827
|
+
|
|
2828
|
+
it('never drags the in edge below t=0 — it declines to move at all', () => {
|
|
2829
|
+
// head is 0s–0.05s, so its in edge has NO legal position: t=0 is already
|
|
2830
|
+
// the floor's ceiling minus a negative. Bounds-then-floor put it at -0.05.
|
|
2831
|
+
const ctx = tinyCtx()
|
|
2832
|
+
const d = new Driver(ctx)
|
|
2833
|
+
d.down(2, TINY_Y) // head's in handle (x 0–50, in zone ≤ 6)
|
|
2834
|
+
const eff = d.move(-100, TINY_Y)
|
|
2835
|
+
expect(of(eff, 'projectChange')).toEqual([])
|
|
2836
|
+
const st = d.machine.state
|
|
2837
|
+
expect(st.kind).toBe('dragging')
|
|
2838
|
+
if (st.kind === 'dragging') expect(st.lastProject).toBe(ctx.project)
|
|
2839
|
+
expect(caption(ctx.project, 'head')).toMatchObject({ start: 0, end: 0.05 })
|
|
2840
|
+
expect(of(d.up(-100, TINY_Y), 'commit')).toEqual([])
|
|
2841
|
+
})
|
|
2842
|
+
|
|
2843
|
+
it('never drags the out edge past totalDuration — it declines to move at all', () => {
|
|
2844
|
+
// tail is 14.95s–15s against a 15s horizon. Bounds-then-floor put its out
|
|
2845
|
+
// edge at 15.05.
|
|
2846
|
+
const ctx = tinyCtx()
|
|
2847
|
+
const d = new Driver(ctx)
|
|
2848
|
+
d.down(14998, TINY_Y) // tail's out handle (x 14950–15000)
|
|
2849
|
+
const eff = d.move(15100, TINY_Y)
|
|
2850
|
+
expect(of(eff, 'projectChange')).toEqual([])
|
|
2851
|
+
const st = d.machine.state
|
|
2852
|
+
expect(st.kind).toBe('dragging')
|
|
2853
|
+
if (st.kind === 'dragging') expect(st.lastProject).toBe(ctx.project)
|
|
2854
|
+
expect(caption(ctx.project, 'tail')).toMatchObject({ start: 14.95, end: 15 })
|
|
2855
|
+
expect(of(d.up(15100, TINY_Y), 'commit')).toEqual([])
|
|
2856
|
+
})
|
|
2857
|
+
|
|
2858
|
+
it('still trims a sub-floor segment that is NOT pinned — the range is not empty', () => {
|
|
2859
|
+
// The guard must decline only when there is genuinely nowhere legal to go.
|
|
2860
|
+
// middle is 5s–5.05s with room on both sides, so dragging its out edge left
|
|
2861
|
+
// grows it to the floor rather than refusing.
|
|
2862
|
+
const d = new Driver(tinyCtx())
|
|
2863
|
+
d.down(5048, TINY_Y) // middle's out handle (x 5000–5050)
|
|
2864
|
+
const trimmed = caption(lastProjectChange(d.move(4900, TINY_Y)), 'middle')
|
|
2865
|
+
expect(trimmed.end).toBeCloseTo(5 + CAPTION_MIN_DURATION_S)
|
|
2866
|
+
expect(trimmed.start).toBe(5)
|
|
2867
|
+
})
|
|
2868
|
+
|
|
2869
|
+
it('keeps every caption inside the timeline however far the pointer travels', () => {
|
|
2870
|
+
// A blanket guard over both edges of all three segments: no drag, at any
|
|
2871
|
+
// distance, may put a caption edge outside [0, totalDuration].
|
|
2872
|
+
const ctx = tinyCtx()
|
|
2873
|
+
// head's in handle, tail's out handle, then both of middle's.
|
|
2874
|
+
for (const x of [2, 14998, 5002, 5048]) {
|
|
2875
|
+
const d = new Driver(ctx)
|
|
2876
|
+
d.down(x, TINY_Y)
|
|
2877
|
+
for (const to of [-9999, 0, 5000, 15000, 99999]) {
|
|
2878
|
+
const changes = of(d.move(to, TINY_Y), 'projectChange')
|
|
2879
|
+
if (changes.length === 0) continue
|
|
2880
|
+
for (const seg of changes[changes.length - 1].project.captions!.segments) {
|
|
2881
|
+
expect(seg.start).toBeGreaterThanOrEqual(0)
|
|
2882
|
+
expect(seg.end).toBeLessThanOrEqual(ctx.totalDuration)
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
})
|
|
2887
|
+
})
|
|
2888
|
+
|
|
2889
|
+
// ── Caption trim on a caption that already overhangs the timeline ─────────
|
|
2890
|
+
//
|
|
2891
|
+
// `totalDuration` is derived from clips and audio only, so a caption can end
|
|
2892
|
+
// past it once clips are trimmed or deleted after captioning. The rule is that
|
|
2893
|
+
// a trim never pushes a caption FURTHER out than it already was — it may not
|
|
2894
|
+
// make an overhang worse, and it may not "helpfully" shrink one either.
|
|
2895
|
+
//
|
|
2896
|
+
// `over` straddles the horizon: start 14.5 inside a 15s timeline, end 16
|
|
2897
|
+
// beyond it. Same 1000px/s viewport, so x = t × 1000.
|
|
2898
|
+
|
|
2899
|
+
function overhangCaptionProject(): Project {
|
|
2900
|
+
return {
|
|
2901
|
+
id: 'p',
|
|
2902
|
+
tracks: [{
|
|
2903
|
+
id: 'trk-0',
|
|
2904
|
+
items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
|
|
2905
|
+
}],
|
|
2906
|
+
captions: {
|
|
2907
|
+
style: 'pop',
|
|
2908
|
+
segments: [{ id: 'over', text: 'trailing', start: 14.5, end: 16 }],
|
|
2909
|
+
},
|
|
2910
|
+
} as unknown as Project
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
const OVERHANG_LAYOUT = computeTimelineLayout(overhangCaptionProject())
|
|
2914
|
+
const OVERHANG_Y = Math.round(OVERHANG_LAYOUT.captions![0].y + OVERHANG_LAYOUT.captions![0].height / 2)
|
|
2915
|
+
/** `over`'s handles: the block spans x 14500–16000. */
|
|
2916
|
+
const OVER_IN_EDGE = 14502
|
|
2917
|
+
const OVER_OUT_EDGE = 15998
|
|
2918
|
+
|
|
2919
|
+
function overhangCtx(overrides: Partial<PointerContext> = {}): PointerContext {
|
|
2920
|
+
return makeContext({ project: overhangCaptionProject(), fps: CAPTION_FPS, viewport: TINY_VIEWPORT, ...overrides })
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
describe('caption trim — a caption that already overhangs the timeline', () => {
|
|
2924
|
+
it('the fixture really does overhang', () => {
|
|
2925
|
+
const ctx = overhangCtx()
|
|
2926
|
+
expect(ctx.totalDuration).toBe(15)
|
|
2927
|
+
expect(caption(ctx.project, 'over').end).toBeGreaterThan(ctx.totalDuration)
|
|
2928
|
+
expect(caption(ctx.project, 'over').start).toBeLessThan(ctx.totalDuration)
|
|
2929
|
+
})
|
|
2930
|
+
|
|
2931
|
+
it('nudges the out edge inward to where the pointer put it, not back to totalDuration', () => {
|
|
2932
|
+
// The whole point of the raised ceiling. Bounded at `totalDuration` this
|
|
2933
|
+
// lands at 15.6 (the duration floor) or 15 — either way a silent shrink of
|
|
2934
|
+
// roughly half a second, triggered by a 50px nudge.
|
|
2935
|
+
const d = new Driver(overhangCtx())
|
|
2936
|
+
d.down(OVER_OUT_EDGE, OVERHANG_Y)
|
|
2937
|
+
const trimmed = caption(lastProjectChange(d.move(OVER_OUT_EDGE - 50, OVERHANG_Y)), 'over')
|
|
2938
|
+
expect(trimmed.end).toBeCloseTo(15.95)
|
|
2939
|
+
expect(trimmed.start).toBe(14.5)
|
|
2940
|
+
})
|
|
2941
|
+
|
|
2942
|
+
it('refuses to push the out edge FURTHER out than it already was', () => {
|
|
2943
|
+
// `seg.end` is both the ceiling and the starting value, so an outward drag
|
|
2944
|
+
// has nowhere legal to go and the trim declines entirely.
|
|
2945
|
+
const ctx = overhangCtx()
|
|
2946
|
+
const d = new Driver(ctx)
|
|
2947
|
+
d.down(OVER_OUT_EDGE, OVERHANG_Y)
|
|
2948
|
+
expect(of(d.move(OVER_OUT_EDGE + 500, OVERHANG_Y), 'projectChange')).toEqual([])
|
|
2949
|
+
const st = d.machine.state
|
|
2950
|
+
expect(st.kind).toBe('dragging')
|
|
2951
|
+
if (st.kind === 'dragging') expect(st.lastProject).toBe(ctx.project)
|
|
2952
|
+
expect(of(d.up(OVER_OUT_EDGE + 500, OVERHANG_Y), 'commit')).toEqual([])
|
|
2953
|
+
})
|
|
2954
|
+
|
|
2955
|
+
it('still enforces the duration floor on an overhanging caption', () => {
|
|
2956
|
+
// The raised ceiling relaxes the OUTER bound only; the floor is untouched.
|
|
2957
|
+
const d = new Driver(overhangCtx())
|
|
2958
|
+
d.down(OVER_OUT_EDGE, OVERHANG_Y)
|
|
2959
|
+
const trimmed = caption(lastProjectChange(d.move(14000, OVERHANG_Y)), 'over')
|
|
2960
|
+
expect(trimmed.end).toBeCloseTo(14.5 + CAPTION_MIN_DURATION_S)
|
|
2961
|
+
expect(trimmed.start).toBe(14.5)
|
|
2962
|
+
})
|
|
2963
|
+
|
|
2964
|
+
it('never pushes a caption further out than it already was, however far the pointer travels', () => {
|
|
2965
|
+
// The overhang counterpart of the in-bounds sweep above: the invariant is
|
|
2966
|
+
// the segment's OWN original end, not `totalDuration`.
|
|
2967
|
+
const ctx = overhangCtx()
|
|
2968
|
+
const original = caption(ctx.project, 'over')
|
|
2969
|
+
for (const x of [OVER_IN_EDGE, OVER_OUT_EDGE]) {
|
|
2970
|
+
const d = new Driver(ctx)
|
|
2971
|
+
d.down(x, OVERHANG_Y)
|
|
2972
|
+
for (const to of [-9999, 0, 14000, 15000, 16500, 99999]) {
|
|
2973
|
+
const changes = of(d.move(to, OVERHANG_Y), 'projectChange')
|
|
2974
|
+
if (changes.length === 0) continue
|
|
2975
|
+
const seg = changes[changes.length - 1].project.captions!.segments[0]
|
|
2976
|
+
expect(seg.start).toBeGreaterThanOrEqual(0)
|
|
2977
|
+
expect(seg.end).toBeLessThanOrEqual(original.end)
|
|
2978
|
+
expect(seg.end - seg.start).toBeGreaterThanOrEqual(CAPTION_MIN_DURATION_S - 1e-9)
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
})
|
|
2982
|
+
})
|
|
2983
|
+
|
|
2984
|
+
// ── Caption ROWS: vertical drag, row creation, per-row clamps ──────────────
|
|
2985
|
+
//
|
|
2986
|
+
// Captions live on lanes now. Lane 0 is the band adjacent to the base video
|
|
2987
|
+
// row and higher lanes stack UPWARD, so a drag toward the top of the surface
|
|
2988
|
+
// RAISES the lane number — the opposite of audio, whose lanes ascend downward.
|
|
2989
|
+
// A lone caption changes rows; a caption travelling with a wider selection
|
|
2990
|
+
// never does.
|
|
2991
|
+
//
|
|
2992
|
+
// Fixture (100px/s, so x = t × 100):
|
|
2993
|
+
//
|
|
2994
|
+
// lane 1 u0 1s–2s (x 100–200)
|
|
2995
|
+
// lane 0 g0 1s–2s (words), g1 5s–6s (x 100–200, 500–600)
|
|
2996
|
+
// row track 0 — c0 0s–10s ⇒ totalDuration 15
|
|
2997
|
+
//
|
|
2998
|
+
// u0 sits directly above g0 on purpose: it is what makes "the row I am aiming
|
|
2999
|
+
// at is already taken" reachable without moving horizontally at all.
|
|
3000
|
+
|
|
3001
|
+
function lanedCaptionProject(): Project {
|
|
3002
|
+
return {
|
|
3003
|
+
id: 'p',
|
|
3004
|
+
tracks: [{
|
|
3005
|
+
id: 'trk-0',
|
|
3006
|
+
items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
|
|
3007
|
+
}],
|
|
3008
|
+
captions: {
|
|
3009
|
+
style: 'pop',
|
|
3010
|
+
segments: [
|
|
3011
|
+
// No `lane` at all — absent means lane 0, and this fixture leans on
|
|
3012
|
+
// that being indistinguishable from an explicit 0.
|
|
3013
|
+
{
|
|
3014
|
+
id: 'g0',
|
|
3015
|
+
text: 'hello there',
|
|
3016
|
+
start: 1,
|
|
3017
|
+
end: 2,
|
|
3018
|
+
words: [
|
|
3019
|
+
{ word: 'hello', start: 1, end: 1.6 },
|
|
3020
|
+
{ word: 'there', start: 1.6, end: 2 },
|
|
3021
|
+
],
|
|
3022
|
+
},
|
|
3023
|
+
{ id: 'g1', text: 'ground later', start: 5, end: 6 },
|
|
3024
|
+
{ id: 'u0', text: 'upstairs', start: 1, end: 2, lane: 1 },
|
|
3025
|
+
],
|
|
3026
|
+
},
|
|
3027
|
+
} as unknown as Project
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
const LANED_LAYOUT = computeTimelineLayout(lanedCaptionProject())
|
|
3031
|
+
const laneMidY = (layout: typeof LANED_LAYOUT, lane: number) => {
|
|
3032
|
+
const band = layout.captions!.find(b => b.lane === lane)!
|
|
3033
|
+
return Math.round(band.y + band.height / 2)
|
|
3034
|
+
}
|
|
3035
|
+
const L0_Y = laneMidY(LANED_LAYOUT, 0)
|
|
3036
|
+
const L1_Y = laneMidY(LANED_LAYOUT, 1)
|
|
3037
|
+
|
|
3038
|
+
const G0_BODY = { x: 150, y: L0_Y }
|
|
3039
|
+
const G1_BODY = { x: 550, y: L0_Y }
|
|
3040
|
+
const U0_BODY = { x: 150, y: L1_Y }
|
|
3041
|
+
const G0_OUT = { x: 198, y: L0_Y }
|
|
3042
|
+
const U0_OUT = { x: 198, y: L1_Y }
|
|
3043
|
+
|
|
3044
|
+
function lanedCtx(overrides: Partial<PointerContext> = {}): PointerContext {
|
|
3045
|
+
return makeContext({ project: lanedCaptionProject(), fps: CAPTION_FPS, ...overrides })
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
/** Every caption lane in a project, keyed by segment id, read the way the
|
|
3049
|
+
* painter reads it (absent ⇒ 0). */
|
|
3050
|
+
function lanes(project: Project): Record<string, number> {
|
|
3051
|
+
const out: Record<string, number> = {}
|
|
3052
|
+
for (const s of project.captions?.segments ?? []) out[s.id as string] = s.lane ?? 0
|
|
3053
|
+
return out
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
describe('caption vertical drag — moving between rows', () => {
|
|
3057
|
+
it('the fixture stacks lane 0 below lane 1, one band apart', () => {
|
|
3058
|
+
expect(LANED_LAYOUT.captions!.map(b => b.lane)).toEqual([1, 0])
|
|
3059
|
+
expect(L0_Y - L1_Y).toBe(CAPTION_ROW_HEIGHT_PX + ROW_GAP_PX)
|
|
3060
|
+
})
|
|
3061
|
+
|
|
3062
|
+
it('a caption dragged UP one band lands on the next lane up', () => {
|
|
3063
|
+
const d = new Driver(lanedCtx())
|
|
3064
|
+
d.down(G1_BODY.x, G1_BODY.y)
|
|
3065
|
+
const moved = lastProjectChange(d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX))
|
|
3066
|
+
expect(lanes(moved).g1).toBe(1)
|
|
3067
|
+
// Purely vertical: the segment keeps its exact timing.
|
|
3068
|
+
expect(caption(moved, 'g1')).toMatchObject({ start: 5, end: 6 })
|
|
3069
|
+
// …and nothing else moved rows.
|
|
3070
|
+
expect(lanes(moved).g0).toBe(0)
|
|
3071
|
+
expect(lanes(moved).u0).toBe(1)
|
|
3072
|
+
})
|
|
3073
|
+
|
|
3074
|
+
it('crosses a row at CAPTION_ROW_HEIGHT_PX of travel, not at the 44px band pitch', () => {
|
|
3075
|
+
// 22px is over half of 40 and exactly half of 44 (which `Math.round`
|
|
3076
|
+
// resolves DOWNWARD in magnitude, to -0). The shorter divisor is what lets
|
|
3077
|
+
// the drag reach the next row before the cursor has fully left this one.
|
|
3078
|
+
expect(Math.round(-22 / CAPTION_ROW_HEIGHT_PX)).toBe(-1)
|
|
3079
|
+
expect(Math.round(-22 / (CAPTION_ROW_HEIGHT_PX + ROW_GAP_PX))).toBe(-0)
|
|
3080
|
+
|
|
3081
|
+
const crossed = new Driver(lanedCtx())
|
|
3082
|
+
crossed.down(G1_BODY.x, G1_BODY.y)
|
|
3083
|
+
expect(lanes(lastProjectChange(crossed.move(G1_BODY.x, G1_BODY.y - 22))).g1).toBe(1)
|
|
3084
|
+
|
|
3085
|
+
// 20px is under half a row: no vertical intent, and with no horizontal
|
|
3086
|
+
// travel either the gesture has nothing at all to emit.
|
|
3087
|
+
const held = new Driver(lanedCtx())
|
|
3088
|
+
held.down(G1_BODY.x, G1_BODY.y)
|
|
3089
|
+
expect(of(held.move(G1_BODY.x, G1_BODY.y - 20), 'projectChange')).toEqual([])
|
|
3090
|
+
})
|
|
3091
|
+
|
|
3092
|
+
it('dragging past the TOP band mints a new row, and the band is there mid-drag', () => {
|
|
3093
|
+
const d = new Driver(lanedCtx())
|
|
3094
|
+
d.down(G1_BODY.x, G1_BODY.y)
|
|
3095
|
+
const moved = lastProjectChange(d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX * 2))
|
|
3096
|
+
expect(lanes(moved).g1).toBe(2)
|
|
3097
|
+
// The band exists in the very frame the drag emits — that is what makes
|
|
3098
|
+
// the new row appear under the cursor rather than at release.
|
|
3099
|
+
expect(computeTimelineLayout(moved).captions!.map(b => b.lane)).toEqual([2, 1, 0])
|
|
3100
|
+
})
|
|
3101
|
+
|
|
3102
|
+
it('clamps at lane 0 dragging DOWN, and never reaches the video row', () => {
|
|
3103
|
+
const d = new Driver(lanedCtx())
|
|
3104
|
+
d.down(G1_BODY.x, G1_BODY.y)
|
|
3105
|
+
// Two rows down from lane 0 is lane -2; the clamp holds it at 0. The
|
|
3106
|
+
// horizontal component is there only so the move is not a no-op and the
|
|
3107
|
+
// landed lane is actually observable.
|
|
3108
|
+
const moved = lastProjectChange(d.move(G1_BODY.x + 100, G1_BODY.y + CAPTION_ROW_HEIGHT_PX * 2))
|
|
3109
|
+
expect(lanes(moved).g1).toBe(0)
|
|
3110
|
+
expect(caption(moved, 'g1')).toMatchObject({ start: 6, end: 7 })
|
|
3111
|
+
|
|
3112
|
+
// A blanket guard: no downward travel, at any distance, puts a caption
|
|
3113
|
+
// below lane 0.
|
|
3114
|
+
for (const dy of [40, 80, 400, 4000]) {
|
|
3115
|
+
const proj = lastProjectChange(d.move(G1_BODY.x + 100, G1_BODY.y + dy))
|
|
3116
|
+
expect(lanes(proj).g1).toBeGreaterThanOrEqual(0)
|
|
3117
|
+
}
|
|
3118
|
+
})
|
|
3119
|
+
|
|
3120
|
+
it('hands a caption to the nearest FREE row when the one it aimed at is taken', () => {
|
|
3121
|
+
// Straight up from g0 is lane 1, where u0 sits at exactly the same span.
|
|
3122
|
+
// The fan-out's next candidate is the row it came from, which fits — so
|
|
3123
|
+
// the caption simply stays put rather than being shoved somewhere it was
|
|
3124
|
+
// not aimed at.
|
|
3125
|
+
const blocked = new Driver(lanedCtx())
|
|
3126
|
+
blocked.down(G0_BODY.x, G0_BODY.y)
|
|
3127
|
+
expect(of(blocked.move(G0_BODY.x, G0_BODY.y - CAPTION_ROW_HEIGHT_PX), 'projectChange')).toEqual([])
|
|
3128
|
+
|
|
3129
|
+
// The same upward drag, landing clear of u0 in time, takes lane 1.
|
|
3130
|
+
const clear = new Driver(lanedCtx())
|
|
3131
|
+
clear.down(G0_BODY.x, G0_BODY.y)
|
|
3132
|
+
const moved = lastProjectChange(clear.move(G0_BODY.x + 250, G0_BODY.y - CAPTION_ROW_HEIGHT_PX))
|
|
3133
|
+
expect(lanes(moved).g0).toBe(1)
|
|
3134
|
+
expect(caption(moved, 'g0')).toMatchObject({ start: 3.5, end: 4.5 })
|
|
3135
|
+
})
|
|
3136
|
+
|
|
3137
|
+
it('fans out to a NEW row when the target row and the source row are both taken', () => {
|
|
3138
|
+
// a0 aims at lane 1 and lands on b1; falling back to lane 0 lands on a1;
|
|
3139
|
+
// so the drop goes to lane 2, which is empty by construction.
|
|
3140
|
+
const crowded: Project = {
|
|
3141
|
+
id: 'p',
|
|
3142
|
+
tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }] }],
|
|
3143
|
+
captions: {
|
|
3144
|
+
style: 'pop',
|
|
3145
|
+
segments: [
|
|
3146
|
+
{ id: 'a0', text: 'a0', start: 1, end: 2 },
|
|
3147
|
+
{ id: 'a1', text: 'a1', start: 5, end: 6 },
|
|
3148
|
+
{ id: 'b1', text: 'b1', start: 5, end: 6, lane: 1 },
|
|
3149
|
+
],
|
|
3150
|
+
},
|
|
3151
|
+
} as unknown as Project
|
|
3152
|
+
const ctx = lanedCtx({ project: crowded, layout: computeTimelineLayout(crowded) })
|
|
3153
|
+
const d = new Driver(ctx)
|
|
3154
|
+
d.down(150, laneMidY(ctx.layout, 0))
|
|
3155
|
+
const moved = lastProjectChange(d.move(150 + 400, laneMidY(ctx.layout, 0) - CAPTION_ROW_HEIGHT_PX))
|
|
3156
|
+
expect(lanes(moved).a0).toBe(2)
|
|
3157
|
+
expect(caption(moved, 'a0')).toMatchObject({ start: 5, end: 6 })
|
|
3158
|
+
// Neither blocker moved, and neither was overlapped in its own row.
|
|
3159
|
+
expect(caption(moved, 'a1')).toMatchObject({ start: 5, end: 6 })
|
|
3160
|
+
expect(caption(moved, 'b1')).toMatchObject({ start: 5, end: 6 })
|
|
3161
|
+
})
|
|
3162
|
+
|
|
3163
|
+
it('carries word timings across a row change, and never touches the text', () => {
|
|
3164
|
+
const d = new Driver(lanedCtx())
|
|
3165
|
+
d.down(G0_BODY.x, G0_BODY.y)
|
|
3166
|
+
const moved = lastProjectChange(d.move(G0_BODY.x + 250, G0_BODY.y - CAPTION_ROW_HEIGHT_PX))
|
|
3167
|
+
const seg = caption(moved, 'g0')
|
|
3168
|
+
expect(seg.text).toBe('hello there')
|
|
3169
|
+
expect(seg.words).toEqual([
|
|
3170
|
+
{ word: 'hello', start: 3.5, end: 4.1 },
|
|
3171
|
+
{ word: 'there', start: 4.1, end: 4.5 },
|
|
3172
|
+
])
|
|
3173
|
+
})
|
|
3174
|
+
|
|
3175
|
+
it('changes rows on the very FIRST move of a freshly grabbed caption', () => {
|
|
3176
|
+
// The stale-selection trap again: `selectMany` has not been echoed back
|
|
3177
|
+
// yet, so `ctx.selectedIds` still holds only the clip. The vertical path
|
|
3178
|
+
// has to cope with its own id being absent from the selection.
|
|
3179
|
+
const d = new Driver(lanedCtx({ selectedIds: ['c0'] }))
|
|
3180
|
+
d.down(G1_BODY.x, G1_BODY.y)
|
|
3181
|
+
expect(lanes(lastProjectChange(d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX))).g1).toBe(1)
|
|
3182
|
+
})
|
|
3183
|
+
|
|
3184
|
+
it('commits a cross-row drag exactly once', () => {
|
|
3185
|
+
const d = new Driver(lanedCtx())
|
|
3186
|
+
d.down(G1_BODY.x, G1_BODY.y)
|
|
3187
|
+
d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX)
|
|
3188
|
+
const committed = of(d.up(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX), 'commit')
|
|
3189
|
+
expect(committed).toHaveLength(1)
|
|
3190
|
+
expect(lanes(committed[0].project).g1).toBe(1)
|
|
3191
|
+
})
|
|
3192
|
+
|
|
3193
|
+
it('commits nothing when the caption ends the drag back in the row it started in', () => {
|
|
3194
|
+
const d = new Driver(lanedCtx())
|
|
3195
|
+
d.down(G1_BODY.x, G1_BODY.y)
|
|
3196
|
+
d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX)
|
|
3197
|
+
d.move(G1_BODY.x, G1_BODY.y)
|
|
3198
|
+
expect(of(d.up(G1_BODY.x, G1_BODY.y), 'commit')).toEqual([])
|
|
3199
|
+
})
|
|
3200
|
+
})
|
|
3201
|
+
|
|
3202
|
+
describe('caption horizontal drag — clamped by its own row only', () => {
|
|
3203
|
+
it('stops a lane-0 caption at its lane-0 neighbour', () => {
|
|
3204
|
+
const d = new Driver(lanedCtx())
|
|
3205
|
+
d.down(G0_BODY.x, G0_BODY.y)
|
|
3206
|
+
// 500px right is 5s; g1 starts at 5, so g0 gets 3s of the travel.
|
|
3207
|
+
const moved = lastProjectChange(d.move(G0_BODY.x + 500, G0_BODY.y))
|
|
3208
|
+
expect(caption(moved, 'g0')).toMatchObject({ start: 4, end: 5 })
|
|
3209
|
+
expect(lanes(moved).g0).toBe(0)
|
|
3210
|
+
})
|
|
3211
|
+
|
|
3212
|
+
it('lets a lane-1 caption slide straight past a lane-0 caption', () => {
|
|
3213
|
+
// The discriminating case for the per-lane clamp: u0 (lane 1) travels the
|
|
3214
|
+
// full 5s even though g1 (lane 0) sits at 5s–6s directly in its path. A
|
|
3215
|
+
// track-wide clamp stopped it at 4s–5s.
|
|
3216
|
+
const d = new Driver(lanedCtx())
|
|
3217
|
+
d.down(U0_BODY.x, U0_BODY.y)
|
|
3218
|
+
const moved = lastProjectChange(d.move(U0_BODY.x + 500, U0_BODY.y))
|
|
3219
|
+
expect(caption(moved, 'u0')).toMatchObject({ start: 6, end: 7 })
|
|
3220
|
+
expect(lanes(moved).u0).toBe(1)
|
|
3221
|
+
})
|
|
3222
|
+
|
|
3223
|
+
it('moves a whole selection horizontally and changes NO lane, however far the pointer travels down or up', () => {
|
|
3224
|
+
const d = new Driver(lanedCtx({ selectedIds: ['g0', 'u0'] }))
|
|
3225
|
+
d.down(G0_BODY.x, G0_BODY.y)
|
|
3226
|
+
for (const dy of [-400, -CAPTION_ROW_HEIGHT_PX, CAPTION_ROW_HEIGHT_PX, 400]) {
|
|
3227
|
+
const moved = lastProjectChange(d.move(G0_BODY.x + 100, G0_BODY.y + dy))
|
|
3228
|
+
expect(caption(moved, 'g0')).toMatchObject({ start: 2, end: 3 })
|
|
3229
|
+
expect(caption(moved, 'u0')).toMatchObject({ start: 2, end: 3 })
|
|
3230
|
+
expect(lanes(moved)).toEqual({ g0: 0, g1: 0, u0: 1 })
|
|
3231
|
+
}
|
|
3232
|
+
})
|
|
3233
|
+
})
|
|
3234
|
+
|
|
3235
|
+
describe('caption trim — bounded by its own row only', () => {
|
|
3236
|
+
it('stops an end trim at the next caption IN ITS OWN LANE', () => {
|
|
3237
|
+
const d = new Driver(lanedCtx())
|
|
3238
|
+
d.down(G0_OUT.x, G0_OUT.y)
|
|
3239
|
+
expect(caption(lastProjectChange(d.move(2000, G0_OUT.y)), 'g0').end).toBeCloseTo(5)
|
|
3240
|
+
})
|
|
3241
|
+
|
|
3242
|
+
it('lets a lane-1 caption trim straight past a lane-0 caption', () => {
|
|
3243
|
+
// u0 has no same-lane neighbour, so its only ceiling is the timeline's own
|
|
3244
|
+
// horizon. Track-wide, g1's start (5) stopped it.
|
|
3245
|
+
const d = new Driver(lanedCtx())
|
|
3246
|
+
d.down(U0_OUT.x, U0_OUT.y)
|
|
3247
|
+
const trimmed = caption(lastProjectChange(d.move(2000, U0_OUT.y)), 'u0')
|
|
3248
|
+
expect(trimmed.end).toBeCloseTo(15)
|
|
3249
|
+
expect(trimmed.start).toBe(1)
|
|
3250
|
+
})
|
|
3251
|
+
|
|
3252
|
+
it('ignores a caption on another row when trimming a start edge', () => {
|
|
3253
|
+
// p0 (lane 0) ends at 2 and q0 (lane 1) starts at 3. Track-wide, q0's in
|
|
3254
|
+
// edge floored at 2; per lane it has no previous neighbour at all and may
|
|
3255
|
+
// run back to t=0.
|
|
3256
|
+
const split: Project = {
|
|
3257
|
+
id: 'p',
|
|
3258
|
+
tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }] }],
|
|
3259
|
+
captions: {
|
|
3260
|
+
style: 'pop',
|
|
3261
|
+
segments: [
|
|
3262
|
+
{ id: 'p0', text: 'p0', start: 1, end: 2 },
|
|
3263
|
+
{ id: 'q0', text: 'q0', start: 3, end: 4, lane: 1 },
|
|
3264
|
+
],
|
|
3265
|
+
},
|
|
3266
|
+
} as unknown as Project
|
|
3267
|
+
const ctx = lanedCtx({ project: split, layout: computeTimelineLayout(split) })
|
|
3268
|
+
const d = new Driver(ctx)
|
|
3269
|
+
d.down(302, laneMidY(ctx.layout, 1))
|
|
3270
|
+
const trimmed = caption(lastProjectChange(d.move(0, laneMidY(ctx.layout, 1))), 'q0')
|
|
3271
|
+
expect(trimmed.start).toBe(0)
|
|
3272
|
+
expect(trimmed.end).toBe(4)
|
|
3273
|
+
})
|
|
3274
|
+
|
|
3275
|
+
it('never moves a caption between rows — a trim is horizontal, always', () => {
|
|
3276
|
+
const d = new Driver(lanedCtx())
|
|
3277
|
+
d.down(G0_OUT.x, G0_OUT.y)
|
|
3278
|
+
const moved = lastProjectChange(d.move(250, G0_OUT.y - CAPTION_ROW_HEIGHT_PX * 3))
|
|
3279
|
+
expect(lanes(moved)).toEqual({ g0: 0, g1: 0, u0: 1 })
|
|
3280
|
+
})
|
|
3281
|
+
})
|
|
3282
|
+
|
|
3283
|
+
// ── Hole lanes, and when they collapse ────────────────────────────────────
|
|
3284
|
+
//
|
|
3285
|
+
// A caption that was alone in its row leaves a HOLE behind it. The hole is
|
|
3286
|
+
// deliberately KEPT for every mid-drag frame — it is what holds an empty band
|
|
3287
|
+
// open so the timeline does not jump a whole row height under the pointer —
|
|
3288
|
+
// and collapsed exactly once, inside the commit, so the move and the
|
|
3289
|
+
// renumbering are one undo entry.
|
|
3290
|
+
|
|
3291
|
+
function threeLaneProject(): Project {
|
|
3292
|
+
return {
|
|
3293
|
+
id: 'p',
|
|
3294
|
+
tracks: [{
|
|
3295
|
+
id: 'trk-0',
|
|
3296
|
+
items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
|
|
3297
|
+
}],
|
|
3298
|
+
captions: {
|
|
3299
|
+
style: 'pop',
|
|
3300
|
+
segments: [
|
|
3301
|
+
{ id: 'low', text: 'low', start: 1, end: 2 },
|
|
3302
|
+
{ id: 'mid', text: 'mid', start: 1, end: 2, lane: 1 }, // alone in its row
|
|
3303
|
+
{ id: 'top', text: 'top', start: 1, end: 2, lane: 2 },
|
|
3304
|
+
],
|
|
3305
|
+
},
|
|
3306
|
+
} as unknown as Project
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
describe('caption lanes — hole collapse at commit', () => {
|
|
3310
|
+
const ctx = () => lanedCtx({ project: threeLaneProject(), layout: computeTimelineLayout(threeLaneProject()) })
|
|
3311
|
+
|
|
3312
|
+
it('keeps the hole open for every mid-drag frame', () => {
|
|
3313
|
+
const c = ctx()
|
|
3314
|
+
const d = new Driver(c)
|
|
3315
|
+
const y = laneMidY(c.layout, 1)
|
|
3316
|
+
d.down(150, y)
|
|
3317
|
+
const moved = lastProjectChange(d.move(150, y - CAPTION_ROW_HEIGHT_PX * 2))
|
|
3318
|
+
// mid vacated lane 1 for the new lane 3; lane 1 is now a hole, and the
|
|
3319
|
+
// band for it is still painted.
|
|
3320
|
+
expect(lanes(moved)).toEqual({ low: 0, mid: 3, top: 2 })
|
|
3321
|
+
expect(computeTimelineLayout(moved).captions!.map(b => b.lane)).toEqual([3, 2, 1, 0])
|
|
3322
|
+
})
|
|
3323
|
+
|
|
3324
|
+
it('collapses the hole once, in the single commit', () => {
|
|
3325
|
+
const c = ctx()
|
|
3326
|
+
const d = new Driver(c)
|
|
3327
|
+
const y = laneMidY(c.layout, 1)
|
|
3328
|
+
d.down(150, y)
|
|
3329
|
+
d.move(150, y - CAPTION_ROW_HEIGHT_PX * 2)
|
|
3330
|
+
const committed = of(d.up(150, y - CAPTION_ROW_HEIGHT_PX * 2), 'commit')
|
|
3331
|
+
expect(committed).toHaveLength(1)
|
|
3332
|
+
// Dense from 0, relative order preserved: low stays bottom, top moves down
|
|
3333
|
+
// into the vacated row, mid lands on top.
|
|
3334
|
+
expect(lanes(committed[0].project)).toEqual({ low: 0, mid: 2, top: 1 })
|
|
3335
|
+
expect(computeTimelineLayout(committed[0].project).captions!.map(b => b.lane)).toEqual([2, 1, 0])
|
|
3336
|
+
})
|
|
3337
|
+
|
|
3338
|
+
it('leaves an already-dense project byte-for-byte alone at commit', () => {
|
|
3339
|
+
// Normalization must not manufacture a change of its own: a drag that
|
|
3340
|
+
// vacates nothing commits exactly what the last frame emitted.
|
|
3341
|
+
const d = new Driver(lanedCtx())
|
|
3342
|
+
d.down(G1_BODY.x, G1_BODY.y)
|
|
3343
|
+
const moved = lastProjectChange(d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX))
|
|
3344
|
+
const committed = of(d.up(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX), 'commit')
|
|
3345
|
+
expect(committed[0].project.captions).toBe(moved.captions)
|
|
3346
|
+
})
|
|
3347
|
+
|
|
3348
|
+
it('does not normalize a caption TRIM — a trim cannot empty a row', () => {
|
|
3349
|
+
const c = ctx()
|
|
3350
|
+
const d = new Driver(c)
|
|
3351
|
+
const y = laneMidY(c.layout, 1)
|
|
3352
|
+
d.down(198, y) // mid's out handle
|
|
3353
|
+
const moved = lastProjectChange(d.move(400, y))
|
|
3354
|
+
const committed = of(d.up(400, y), 'commit')
|
|
3355
|
+
expect(committed[0].project.captions).toBe(moved.captions)
|
|
3356
|
+
expect(lanes(committed[0].project)).toEqual({ low: 0, mid: 1, top: 2 })
|
|
3357
|
+
})
|
|
3358
|
+
})
|