@bycrux/editor 0.11.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* SP5 T5 — the one snapping model. At 100px/second the default radii convert to
|
|
4
|
+
* round numbers, which is what makes the hysteresis assertions readable:
|
|
5
|
+
*
|
|
6
|
+
* attract 20px → 0.20s release 44px → 0.44s
|
|
7
|
+
*
|
|
8
|
+
* The gap between them is the feature, not an implementation detail: a magnet
|
|
9
|
+
* you can leave as easily as you entered it reads as the timeline refusing to
|
|
10
|
+
* go where you put it. Several assertions below exist purely to pin that gap,
|
|
11
|
+
* so widening or narrowing it is always a deliberate edit.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_SNAP_CONFIG,
|
|
16
|
+
applySnap,
|
|
17
|
+
createSnapState,
|
|
18
|
+
snapPointsExcluding,
|
|
19
|
+
snapPointsForSpan,
|
|
20
|
+
type SnapPoint,
|
|
21
|
+
} from '../snap'
|
|
22
|
+
import type { Viewport } from '../viewport'
|
|
23
|
+
|
|
24
|
+
const VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
|
|
25
|
+
|
|
26
|
+
/** Same-track boundaries — the full-strength magnet. */
|
|
27
|
+
const strong = (...times: number[]): SnapPoint[] => times.map(time => ({ time, strength: 'strong' }))
|
|
28
|
+
/** Boundaries on some other row: 6px in, 10px out. */
|
|
29
|
+
const weak = (...times: number[]): SnapPoint[] => times.map(time => ({ time, strength: 'weak' }))
|
|
30
|
+
|
|
31
|
+
const POINTS = strong(5)
|
|
32
|
+
|
|
33
|
+
describe('applySnap — attraction', () => {
|
|
34
|
+
it('captures a candidate inside the attract radius', () => {
|
|
35
|
+
const result = applySnap(5.1, POINTS, VIEWPORT, createSnapState())
|
|
36
|
+
expect(result.time).toBe(5)
|
|
37
|
+
expect(result.snappedTo).toBe(5)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('leaves a candidate outside the attract radius alone', () => {
|
|
41
|
+
const result = applySnap(5.25, POINTS, VIEWPORT, createSnapState())
|
|
42
|
+
expect(result.time).toBe(5.25)
|
|
43
|
+
expect(result.snappedTo).toBeNull()
|
|
44
|
+
expect(result.state.snappedTo).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('brackets the attract radius', () => {
|
|
48
|
+
expect(applySnap(5.201, POINTS, VIEWPORT, createSnapState()).snappedTo).toBeNull()
|
|
49
|
+
expect(applySnap(5.199, POINTS, VIEWPORT, createSnapState()).snappedTo).toBe(5)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('attracts from either side', () => {
|
|
53
|
+
expect(applySnap(4.9, POINTS, VIEWPORT, createSnapState()).time).toBe(5)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('applySnap — hysteresis', () => {
|
|
58
|
+
it('holds a captured point until the release radius is cleared', () => {
|
|
59
|
+
let state = createSnapState()
|
|
60
|
+
|
|
61
|
+
// Approach and stick.
|
|
62
|
+
let result = applySnap(5.15, POINTS, VIEWPORT, state)
|
|
63
|
+
expect(result.time).toBe(5)
|
|
64
|
+
state = result.state
|
|
65
|
+
|
|
66
|
+
// Pull past the ATTRACT radius — still held, because release is wider.
|
|
67
|
+
result = applySnap(5.25, POINTS, VIEWPORT, state)
|
|
68
|
+
expect(result.time).toBe(5)
|
|
69
|
+
expect(result.snappedTo).toBe(5)
|
|
70
|
+
state = result.state
|
|
71
|
+
|
|
72
|
+
// Just short of release — still held.
|
|
73
|
+
result = applySnap(5.439, POINTS, VIEWPORT, state)
|
|
74
|
+
expect(result.time).toBe(5)
|
|
75
|
+
state = result.state
|
|
76
|
+
|
|
77
|
+
// Past release — free, and reporting the true candidate.
|
|
78
|
+
result = applySnap(5.44, POINTS, VIEWPORT, state)
|
|
79
|
+
expect(result.time).toBe(5.44)
|
|
80
|
+
expect(result.snappedTo).toBeNull()
|
|
81
|
+
state = result.state
|
|
82
|
+
|
|
83
|
+
// Coming back inside attract re-captures.
|
|
84
|
+
expect(applySnap(5.1, POINTS, VIEWPORT, state).time).toBe(5)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('cannot immediately re-capture the point it just released', () => {
|
|
88
|
+
const held = applySnap(5.05, POINTS, VIEWPORT, createSnapState()).state
|
|
89
|
+
const released = applySnap(5.5, POINTS, VIEWPORT, held)
|
|
90
|
+
expect(released.time).toBe(5.5)
|
|
91
|
+
expect(released.snappedTo).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('hands off to a different point when the escape lands near one', () => {
|
|
95
|
+
const points = strong(5, 5.6)
|
|
96
|
+
const held = applySnap(5.02, points, VIEWPORT, createSnapState()).state
|
|
97
|
+
const moved = applySnap(5.5, points, VIEWPORT, held)
|
|
98
|
+
// 0.50 from the held point clears release (0.44); 0.10 from the neighbour
|
|
99
|
+
// is well inside attract.
|
|
100
|
+
expect(moved.time).toBe(5.6)
|
|
101
|
+
expect(moved.snappedTo).toBe(5.6)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('applySnap — strength tiers', () => {
|
|
106
|
+
it('reports which tier caught the gesture', () => {
|
|
107
|
+
expect(applySnap(5.1, strong(5), VIEWPORT, createSnapState()).strength).toBe('strong')
|
|
108
|
+
expect(applySnap(5.03, weak(5), VIEWPORT, createSnapState()).strength).toBe('weak')
|
|
109
|
+
expect(applySnap(9, strong(5), VIEWPORT, createSnapState()).strength).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('ignores a cross-track boundary that a same-track one would have caught', () => {
|
|
113
|
+
// 0.1s is 10px: inside the 20px strong radius, well outside the 6px weak
|
|
114
|
+
// one. This is the whole point of the split — an overlay row full of cuts
|
|
115
|
+
// must not grab a clip being dragged on the row below.
|
|
116
|
+
expect(applySnap(5.1, strong(5), VIEWPORT, createSnapState()).snappedTo).toBe(5)
|
|
117
|
+
expect(applySnap(5.1, weak(5), VIEWPORT, createSnapState()).snappedTo).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('lets a strong point beat a NEARER weak one', () => {
|
|
121
|
+
// Nearest-wins holds within a tier, never across it: the same-track cut at
|
|
122
|
+
// 0.15s out takes it, not the overlay edge 0.02s away.
|
|
123
|
+
const points = [...weak(5.02), ...strong(5.15)]
|
|
124
|
+
const result = applySnap(5, points, VIEWPORT, createSnapState())
|
|
125
|
+
expect(result.snappedTo).toBe(5.15)
|
|
126
|
+
expect(result.strength).toBe('strong')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('falls back to a weak point when no strong one is in range', () => {
|
|
130
|
+
const points = [...weak(5.02), ...strong(9)]
|
|
131
|
+
expect(applySnap(5, points, VIEWPORT, createSnapState()).snappedTo).toBe(5.02)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('lets a weak capture go on weak terms — 10px, not 44', () => {
|
|
135
|
+
// A subtle magnet has to be subtle to LEAVE as well as to enter, or it is
|
|
136
|
+
// just a strong magnet that was hard to trigger.
|
|
137
|
+
const held = applySnap(5.02, weak(5), VIEWPORT, createSnapState()).state
|
|
138
|
+
expect(applySnap(5.09, weak(5), VIEWPORT, held).snappedTo).toBe(5)
|
|
139
|
+
expect(applySnap(5.11, weak(5), VIEWPORT, held).snappedTo).toBeNull()
|
|
140
|
+
|
|
141
|
+
// The same distance would not have shaken off a strong one.
|
|
142
|
+
const heldStrong = applySnap(5.02, strong(5), VIEWPORT, createSnapState()).state
|
|
143
|
+
expect(applySnap(5.11, strong(5), VIEWPORT, heldStrong).snappedTo).toBe(5)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('applySnap — multiple candidates', () => {
|
|
148
|
+
it('takes the nearest point, not the first in range', () => {
|
|
149
|
+
expect(applySnap(5, strong(4.9, 5.05), VIEWPORT, createSnapState()).time).toBe(5.05)
|
|
150
|
+
expect(applySnap(5, strong(5.05, 4.9), VIEWPORT, createSnapState()).time).toBe(5.05)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('breaks an exact tie in favour of the earlier entry', () => {
|
|
154
|
+
expect(applySnap(5, strong(4.9, 5.1), VIEWPORT, createSnapState()).time).toBe(4.9)
|
|
155
|
+
expect(applySnap(5, strong(5.1, 4.9), VIEWPORT, createSnapState()).time).toBe(5.1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('ignores points outside the radius entirely', () => {
|
|
159
|
+
expect(applySnap(5, strong(1, 9), VIEWPORT, createSnapState()).snappedTo).toBeNull()
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('applySnap — configuration and degenerate viewports', () => {
|
|
164
|
+
it('ships radii whose release is more than double its attract', () => {
|
|
165
|
+
expect(DEFAULT_SNAP_CONFIG).toEqual({ attractPx: 20, releasePx: 44, weakAttractPx: 6, weakReleasePx: 10 })
|
|
166
|
+
// The detent. Pinned as a ratio as well as absolutes so a future tweak to
|
|
167
|
+
// either number has to keep the asymmetry that makes the magnet hold.
|
|
168
|
+
expect(DEFAULT_SNAP_CONFIG.releasePx).toBeGreaterThan(DEFAULT_SNAP_CONFIG.attractPx * 2)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('holds well past the distance that captured it', () => {
|
|
172
|
+
// The user-facing promise: sliding a clip a little way past a cut does not
|
|
173
|
+
// shake it off. 0.3s is 30px — half again the attract radius — and the
|
|
174
|
+
// gesture is still pinned to the boundary.
|
|
175
|
+
const held = applySnap(5.05, POINTS, VIEWPORT, createSnapState()).state
|
|
176
|
+
expect(applySnap(5.3, POINTS, VIEWPORT, held).snappedTo).toBe(5)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('honours a config override', () => {
|
|
180
|
+
const tight = { attractPx: 4, releasePx: 6, weakAttractPx: 2, weakReleasePx: 3 }
|
|
181
|
+
// 0.03s is 3px — inside a 4px magnet, and well inside the default 20px one.
|
|
182
|
+
expect(applySnap(5.03, POINTS, VIEWPORT, createSnapState(), tight).time).toBe(5)
|
|
183
|
+
expect(applySnap(5.1, POINTS, VIEWPORT, createSnapState(), tight).snappedTo).toBeNull()
|
|
184
|
+
expect(applySnap(5.1, POINTS, VIEWPORT, createSnapState()).snappedTo).toBe(5)
|
|
185
|
+
// …and the tighter release lets go sooner.
|
|
186
|
+
const held = applySnap(5.03, POINTS, VIEWPORT, createSnapState(), tight).state
|
|
187
|
+
expect(applySnap(5.07, POINTS, VIEWPORT, held, tight).snappedTo).toBeNull()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('scales the radii with zoom, so the magnet is the same size on screen', () => {
|
|
191
|
+
const zoomedIn: Viewport = { ...VIEWPORT, pxPerSecond: 1000 }
|
|
192
|
+
// 0.1s is 100px out at this scale — far outside the magnet.
|
|
193
|
+
expect(applySnap(5.1, POINTS, zoomedIn, createSnapState()).snappedTo).toBeNull()
|
|
194
|
+
// 0.01s is 10px — inside it.
|
|
195
|
+
expect(applySnap(5.01, POINTS, zoomedIn, createSnapState()).time).toBe(5)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('does not snap before the surface has a scale', () => {
|
|
199
|
+
const unscaled: Viewport = { pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }
|
|
200
|
+
const result = applySnap(5.0001, POINTS, unscaled, createSnapState())
|
|
201
|
+
expect(result.time).toBe(5.0001)
|
|
202
|
+
expect(result.snappedTo).toBeNull()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('does not snap when there are no points', () => {
|
|
206
|
+
expect(applySnap(5, [], VIEWPORT, createSnapState()).time).toBe(5)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('defaults to a free state when none is threaded', () => {
|
|
210
|
+
expect(applySnap(5.1, POINTS, VIEWPORT).time).toBe(5)
|
|
211
|
+
expect(createSnapState().snappedTo).toBeNull()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('snapPointsForSpan', () => {
|
|
216
|
+
it('offers each point to both edges of the span', () => {
|
|
217
|
+
expect(snapPointsForSpan(strong(2, 8), 3)).toEqual(strong(2, 8, -1, 5))
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('carries each point\'s strength onto its trailing-edge twin', () => {
|
|
221
|
+
// Otherwise a cross-track boundary would pull weakly on a clip's head and
|
|
222
|
+
// at full strength on its tail, which is indefensible either way round.
|
|
223
|
+
expect(snapPointsForSpan(weak(8), 3)).toEqual(weak(8, 5))
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('lets a moving item catch a boundary by its trailing edge', () => {
|
|
227
|
+
// A 3s item whose end should land on t=8 must start at t=5.
|
|
228
|
+
const points = snapPointsForSpan(strong(8), 3)
|
|
229
|
+
expect(applySnap(5.1, points, VIEWPORT, createSnapState()).time).toBe(5)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
describe('snapPointsExcluding', () => {
|
|
234
|
+
it('drops the values the gesture is itself moving', () => {
|
|
235
|
+
expect(snapPointsExcluding(strong(0, 2, 5, 10), [2, 5])).toEqual(strong(0, 10))
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('de-duplicates and drops non-finite entries', () => {
|
|
239
|
+
expect(snapPointsExcluding(strong(3, 3, Infinity, NaN, 7), [])).toEqual(strong(3, 7))
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('upgrades a duplicate to strong, whichever order the rows arrived in', () => {
|
|
243
|
+
// A cut on this track that happens to line up with one two rows up is a
|
|
244
|
+
// same-track boundary. Losing that to a weak twin would silently punch a
|
|
245
|
+
// hole in the strong tier.
|
|
246
|
+
expect(snapPointsExcluding([...weak(4), ...strong(4)], [])).toEqual(strong(4))
|
|
247
|
+
expect(snapPointsExcluding([...strong(4), ...weak(4)], [])).toEqual(strong(4))
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('matches within a float epsilon, so recomputed times still cancel', () => {
|
|
251
|
+
expect(snapPointsExcluding(strong(0.1 + 0.2), [0.3])).toEqual([])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('keeps everything when nothing is excluded', () => {
|
|
255
|
+
expect(snapPointsExcluding(strong(1, 2), [])).toEqual(strong(1, 2))
|
|
256
|
+
})
|
|
257
|
+
})
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP5 T4 — the canvas timeline's viewport math. Everything asserted here is
|
|
3
|
+
* pure: the surface plumbing (ResizeObserver, matchMedia) is exercised only
|
|
4
|
+
* through its size arithmetic, which is the part that can be wrong silently.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
7
|
+
import {
|
|
8
|
+
EDGE_SCROLL_MAX_PX_PER_SEC,
|
|
9
|
+
EDGE_SCROLL_RAMP_PX,
|
|
10
|
+
EDGE_SCROLL_ZONE_PX,
|
|
11
|
+
MAX_PX_PER_SECOND,
|
|
12
|
+
MIN_FIT_MULTIPLE,
|
|
13
|
+
MIN_PX_PER_SECOND,
|
|
14
|
+
OVERSCROLL_FRACTION,
|
|
15
|
+
applyWheelIntent,
|
|
16
|
+
backingStoreSize,
|
|
17
|
+
clampPxPerSecond,
|
|
18
|
+
clampScrollSeconds,
|
|
19
|
+
createViewportStore,
|
|
20
|
+
edgeScrollDelta,
|
|
21
|
+
fitPxPerSecond,
|
|
22
|
+
fitViewport,
|
|
23
|
+
formatZoomMultiple,
|
|
24
|
+
maxScrollSeconds,
|
|
25
|
+
panByPixels,
|
|
26
|
+
reclampForDuration,
|
|
27
|
+
scaleContextToDpr,
|
|
28
|
+
syncCanvasBackingStore,
|
|
29
|
+
timeToX,
|
|
30
|
+
visibleDuration,
|
|
31
|
+
visibleRange,
|
|
32
|
+
wheelIntent,
|
|
33
|
+
withSurfaceWidth,
|
|
34
|
+
xToTime,
|
|
35
|
+
zoomAtPivot,
|
|
36
|
+
zoomMultiple,
|
|
37
|
+
type Viewport,
|
|
38
|
+
} from '../viewport'
|
|
39
|
+
|
|
40
|
+
function vp(overrides: Partial<Viewport> = {}): Viewport {
|
|
41
|
+
return { pxPerSecond: 10, scrollSeconds: 0, widthPx: 1000, ...overrides }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('time ↔ pixel conversion', () => {
|
|
45
|
+
it('maps time to x through scroll and scale', () => {
|
|
46
|
+
const v = vp({ pxPerSecond: 20, scrollSeconds: 5 })
|
|
47
|
+
expect(timeToX(5, v)).toBe(0)
|
|
48
|
+
expect(timeToX(10, v)).toBe(100)
|
|
49
|
+
expect(timeToX(0, v)).toBe(-100)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('round-trips x → time → x', () => {
|
|
53
|
+
const v = vp({ pxPerSecond: 37.5, scrollSeconds: 12.25 })
|
|
54
|
+
for (const x of [0, 1, 250, 999.5]) {
|
|
55
|
+
expect(xToTime(timeToX(xToTime(x, v), v), v)).toBeCloseTo(xToTime(x, v), 10)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reports the visible window from width and scale', () => {
|
|
60
|
+
const v = vp({ pxPerSecond: 50, scrollSeconds: 3, widthPx: 500 })
|
|
61
|
+
expect(visibleDuration(v)).toBe(10)
|
|
62
|
+
expect(visibleRange(v)).toEqual({ start: 3, end: 13 })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('degrades safely before the first layout (no width, no scale)', () => {
|
|
66
|
+
const v = vp({ pxPerSecond: 0, widthPx: 0 })
|
|
67
|
+
expect(visibleDuration(v)).toBe(0)
|
|
68
|
+
expect(xToTime(123, v)).toBe(v.scrollSeconds)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('zoom clamping', () => {
|
|
73
|
+
it('fits the project across the surface', () => {
|
|
74
|
+
expect(fitPxPerSecond(1000, 50)).toBe(20)
|
|
75
|
+
expect(fitPxPerSecond(0, 50)).toBe(0)
|
|
76
|
+
expect(fitPxPerSecond(1000, 0)).toBe(0)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('allows zooming out below fit — the DOM model could not', () => {
|
|
80
|
+
const widthPx = 1000
|
|
81
|
+
const totalDuration = 50
|
|
82
|
+
const fit = fitPxPerSecond(widthPx, totalDuration) // 20 px/s
|
|
83
|
+
expect(clampPxPerSecond(fit * 0.5, widthPx, totalDuration)).toBe(fit * 0.5)
|
|
84
|
+
// …but not indefinitely: the floor is a quarter of fit.
|
|
85
|
+
expect(clampPxPerSecond(0.001, widthPx, totalDuration)).toBe(fit * MIN_FIT_MULTIPLE)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('never goes below the absolute floor on very long projects', () => {
|
|
89
|
+
// fit here is 0.1 px/s; a quarter of that is unusable, so the floor wins.
|
|
90
|
+
expect(clampPxPerSecond(0.0001, 1000, 10_000)).toBe(MIN_PX_PER_SECOND)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('caps zoom-in', () => {
|
|
94
|
+
expect(clampPxPerSecond(1e6, 1000, 50)).toBe(MAX_PX_PER_SECOND)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('reports zoom as a fit-relative multiple, for continuity with the DOM badge', () => {
|
|
98
|
+
expect(zoomMultiple(vp({ pxPerSecond: 20, widthPx: 1000 }), 50)).toBe(1)
|
|
99
|
+
expect(zoomMultiple(vp({ pxPerSecond: 40, widthPx: 1000 }), 50)).toBe(2)
|
|
100
|
+
expect(zoomMultiple(vp({ pxPerSecond: 5, widthPx: 1000 }), 50)).toBe(0.25)
|
|
101
|
+
expect(zoomMultiple(vp({ widthPx: 0 }), 50)).toBe(1)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('formats the badge with one decimal below 10×', () => {
|
|
105
|
+
expect(formatZoomMultiple(1)).toBe('1.0')
|
|
106
|
+
expect(formatZoomMultiple(2.34)).toBe('2.3')
|
|
107
|
+
expect(formatZoomMultiple(12.4)).toBe('12')
|
|
108
|
+
expect(formatZoomMultiple(Number.NaN)).toBe('1')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('scroll clamping', () => {
|
|
113
|
+
it('stops at the content end plus a fraction of a screenful', () => {
|
|
114
|
+
const v = vp({ pxPerSecond: 10, widthPx: 1000 }) // 100s visible
|
|
115
|
+
const totalDuration = 300
|
|
116
|
+
expect(maxScrollSeconds(v, totalDuration)).toBe(300 - 100 + 100 * OVERSCROLL_FRACTION)
|
|
117
|
+
expect(clampScrollSeconds(1e6, v, totalDuration)).toBe(maxScrollSeconds(v, totalDuration))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('pins to zero when the whole project already fits', () => {
|
|
121
|
+
const v = vp({ pxPerSecond: 10, widthPx: 1000 }) // 100s visible
|
|
122
|
+
expect(maxScrollSeconds(v, 30)).toBe(0)
|
|
123
|
+
expect(clampScrollSeconds(20, v, 30)).toBe(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('never scrolls before t=0', () => {
|
|
127
|
+
expect(clampScrollSeconds(-12, vp(), 500)).toBe(0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('pans by pixels in the current scale', () => {
|
|
131
|
+
const v = vp({ pxPerSecond: 50, scrollSeconds: 10, widthPx: 500 })
|
|
132
|
+
expect(panByPixels(v, 250, 1000).scrollSeconds).toBe(15)
|
|
133
|
+
expect(panByPixels(v, -250, 1000).scrollSeconds).toBe(5)
|
|
134
|
+
expect(panByPixels(v, -1e6, 1000).scrollSeconds).toBe(0)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('edge auto-scroll delta', () => {
|
|
139
|
+
// A round scale (100 px/s) keeps the arithmetic legible: 1 content px is
|
|
140
|
+
// 0.01s, so the 1400 px/s cap is 14s of timeline per second of hold.
|
|
141
|
+
const pxPerSecond = 100
|
|
142
|
+
const canvasWidth = 1000
|
|
143
|
+
const zone = EDGE_SCROLL_ZONE_PX // 40
|
|
144
|
+
const maxSpeed = EDGE_SCROLL_MAX_PX_PER_SEC // 1400 px/s
|
|
145
|
+
const ramp = EDGE_SCROLL_RAMP_PX // 70 — the last 30px of it lie PAST the surface edge
|
|
146
|
+
|
|
147
|
+
/** The pan the curve owes for a given depth past the zone's outer boundary:
|
|
148
|
+
* `(depth / ramp) ^ 1.5` of the cap, converted to timeline seconds. Spelled
|
|
149
|
+
* out once here so the expectations below read as the curve's definition
|
|
150
|
+
* rather than as copied magic numbers. */
|
|
151
|
+
const expectedSpeed = (depthPx: number) =>
|
|
152
|
+
(maxSpeed * Math.pow(Math.min(1, depthPx / ramp), 1.5)) / pxPerSecond
|
|
153
|
+
|
|
154
|
+
/** `edgeScrollDelta` against the fixture above; `dt` defaults to a full
|
|
155
|
+
* second so the returned value reads directly as seconds/second. */
|
|
156
|
+
const at = (pointerX: number, dt = 1) =>
|
|
157
|
+
edgeScrollDelta(pointerX, canvasWidth, pxPerSecond, dt, zone, maxSpeed, ramp)
|
|
158
|
+
|
|
159
|
+
it('is zero for a pointer in the middle of the surface', () => {
|
|
160
|
+
expect(at(500)).toBe(0)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('is zero exactly at the zone boundary, on either side', () => {
|
|
164
|
+
expect(at(zone)).toBe(0)
|
|
165
|
+
expect(at(canvasWidth - zone)).toBe(0)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('pans left (negative) inside the left zone, right (positive) inside the right zone', () => {
|
|
169
|
+
expect(at(10)).toBeLessThan(0)
|
|
170
|
+
expect(at(canvasWidth - 10)).toBeGreaterThan(0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('ramps with how deep past the zone boundary the pointer sits, eased rather than linear', () => {
|
|
174
|
+
// Depths of 10px and 30px past the boundary, both still inside the surface.
|
|
175
|
+
const shallow = at(zone - 10)
|
|
176
|
+
const deep = at(zone - 30)
|
|
177
|
+
expect(Math.abs(shallow)).toBeCloseTo(expectedSpeed(10), 10)
|
|
178
|
+
expect(Math.abs(deep)).toBeCloseTo(expectedSpeed(30), 10)
|
|
179
|
+
// Eased, so tripling the depth MORE than triples the speed — a linear ramp
|
|
180
|
+
// would land exactly on 3x.
|
|
181
|
+
expect(Math.abs(deep)).toBeGreaterThan(Math.abs(shallow) * 3)
|
|
182
|
+
|
|
183
|
+
const shallowRight = at(canvasWidth - (zone - 10))
|
|
184
|
+
const deepRight = at(canvasWidth - (zone - 30))
|
|
185
|
+
expect(shallowRight).toBeCloseTo(-shallow, 10) // mirror image of the left side
|
|
186
|
+
expect(deepRight).toBeCloseTo(-deep, 10)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('keeps accelerating PAST the surface edge, not only inside the zone', () => {
|
|
190
|
+
// This is the whole reason the ramp is wider than the zone. A drag reaches
|
|
191
|
+
// the surface edge in one flick, so a curve that maxed out there would make
|
|
192
|
+
// every drag feel like one flat speed no matter how hard it was pushed.
|
|
193
|
+
const atEdge = at(0)
|
|
194
|
+
const past15 = at(-15)
|
|
195
|
+
const past30 = at(-30)
|
|
196
|
+
expect(Math.abs(past15)).toBeGreaterThan(Math.abs(atEdge))
|
|
197
|
+
expect(Math.abs(past30)).toBeGreaterThan(Math.abs(past15))
|
|
198
|
+
// And the edge itself is well under the cap — a nudge, not a launch.
|
|
199
|
+
expect(Math.abs(atEdge)).toBeCloseTo(expectedSpeed(zone), 10)
|
|
200
|
+
expect(Math.abs(atEdge)).toBeLessThan(maxSpeed / pxPerSecond / 2)
|
|
201
|
+
|
|
202
|
+
// Mirrored on the right edge, same depths.
|
|
203
|
+
expect(at(canvasWidth)).toBeCloseTo(-atEdge, 10)
|
|
204
|
+
expect(at(canvasWidth + 15)).toBeCloseTo(-past15, 10)
|
|
205
|
+
expect(at(canvasWidth + 30)).toBeCloseTo(-past30, 10)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('clamps at the max once the pointer is a full ramp past the boundary', () => {
|
|
209
|
+
// The document-level drag listeners can report an x arbitrarily far outside
|
|
210
|
+
// the surface once the real cursor leaves it — depth must clamp there, not
|
|
211
|
+
// keep growing. `zone - ramp` is exactly where the ramp is spent.
|
|
212
|
+
const rampSpent = at(zone - ramp)
|
|
213
|
+
expect(rampSpent).toBeCloseTo(-maxSpeed / pxPerSecond, 10)
|
|
214
|
+
expect(at(-500)).toBe(rampSpent)
|
|
215
|
+
|
|
216
|
+
const rightRampSpent = at(canvasWidth - zone + ramp)
|
|
217
|
+
expect(rightRampSpent).toBeCloseTo(maxSpeed / pxPerSecond, 10)
|
|
218
|
+
expect(at(canvasWidth + 500)).toBe(rightRampSpent)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('scales with the frame delta — framerate independent', () => {
|
|
222
|
+
const oneFrame = at(10, 1 / 60)
|
|
223
|
+
const doubleFrame = at(10, 2 / 60)
|
|
224
|
+
expect(doubleFrame).toBeCloseTo(oneFrame * 2, 10)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('is zero for a non-positive frame delta, px/second, zone, ramp, or surface width', () => {
|
|
228
|
+
expect(edgeScrollDelta(10, canvasWidth, pxPerSecond, 0, zone, maxSpeed, ramp)).toBe(0)
|
|
229
|
+
expect(edgeScrollDelta(10, canvasWidth, 0, 1, zone, maxSpeed, ramp)).toBe(0)
|
|
230
|
+
expect(edgeScrollDelta(10, canvasWidth, pxPerSecond, 1, 0, maxSpeed, ramp)).toBe(0)
|
|
231
|
+
expect(edgeScrollDelta(10, canvasWidth, pxPerSecond, 1, zone, maxSpeed, 0)).toBe(0)
|
|
232
|
+
expect(edgeScrollDelta(10, 0, pxPerSecond, 1, zone, maxSpeed, ramp)).toBe(0)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('converts content px/second to timeline seconds through the current scale', () => {
|
|
236
|
+
// At the surface edge, a 200 px/s zoom halves the seconds moved compared to
|
|
237
|
+
// the 100 px/s fixture above for the same content speed.
|
|
238
|
+
const atZoomedIn = edgeScrollDelta(0, canvasWidth, 200, 1, zone, maxSpeed, ramp)
|
|
239
|
+
expect(atZoomedIn).toBeCloseTo(at(0) / 2, 10)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('pivot-preserving zoom', () => {
|
|
244
|
+
it('keeps the time under the cursor stationary', () => {
|
|
245
|
+
// Scrolled well into the timeline, so zooming out either way has room and
|
|
246
|
+
// the clamps (covered separately below) never enter the picture.
|
|
247
|
+
const v = vp({ pxPerSecond: 20, scrollSeconds: 100, widthPx: 1000 })
|
|
248
|
+
const totalDuration = 600
|
|
249
|
+
const pivotX = 320
|
|
250
|
+
const pivotTime = xToTime(pivotX, v)
|
|
251
|
+
|
|
252
|
+
for (const factor of [1.25, 0.8, 2, 0.5]) {
|
|
253
|
+
const next = zoomAtPivot(v, factor, pivotX, totalDuration)
|
|
254
|
+
expect(next.pxPerSecond).toBeCloseTo(v.pxPerSecond * factor, 10)
|
|
255
|
+
expect(xToTime(pivotX, next)).toBeCloseTo(pivotTime, 10)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('holds the pivot across a chain of wheel ticks', () => {
|
|
260
|
+
let v = vp({ pxPerSecond: 20, scrollSeconds: 30, widthPx: 1000 })
|
|
261
|
+
const totalDuration = 600
|
|
262
|
+
const pivotX = 640
|
|
263
|
+
const pivotTime = xToTime(pivotX, v)
|
|
264
|
+
for (let i = 0; i < 12; i++) {
|
|
265
|
+
v = zoomAtPivot(v, Math.exp(-(-100) * 0.002), pivotX, totalDuration)
|
|
266
|
+
}
|
|
267
|
+
expect(xToTime(pivotX, v)).toBeCloseTo(pivotTime, 8)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('gives up the pivot only where scroll would leave the legal range', () => {
|
|
271
|
+
// Pivot near the left edge while already scrolled to 0: holding it would
|
|
272
|
+
// require scrolling before t=0.
|
|
273
|
+
const v = vp({ pxPerSecond: 20, scrollSeconds: 0, widthPx: 1000 })
|
|
274
|
+
const next = zoomAtPivot(v, 0.5, 10, 600)
|
|
275
|
+
expect(next.scrollSeconds).toBe(0)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('clamps the scale, and keeps scroll legal when it does', () => {
|
|
279
|
+
const v = vp({ pxPerSecond: 1900, scrollSeconds: 100, widthPx: 1000 })
|
|
280
|
+
const next = zoomAtPivot(v, 4, 500, 600)
|
|
281
|
+
expect(next.pxPerSecond).toBe(MAX_PX_PER_SECOND)
|
|
282
|
+
expect(next.scrollSeconds).toBeGreaterThanOrEqual(0)
|
|
283
|
+
expect(next.scrollSeconds).toBeLessThanOrEqual(maxScrollSeconds(next, 600))
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('fits to the surface and rewinds to the start', () => {
|
|
287
|
+
const fitted = fitViewport(vp({ pxPerSecond: 900, scrollSeconds: 42 }), 50)
|
|
288
|
+
expect(fitted.pxPerSecond).toBe(20)
|
|
289
|
+
expect(fitted.scrollSeconds).toBe(0)
|
|
290
|
+
expect(zoomMultiple(fitted, 50)).toBe(1)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
describe('wheel semantics (ported from useTimelineZoom)', () => {
|
|
295
|
+
const at = (over: Partial<Parameters<typeof wheelIntent>[0]>) =>
|
|
296
|
+
wheelIntent({ deltaX: 0, deltaY: 0, ctrlKey: false, metaKey: false, altKey: false, ...over }, 400)
|
|
297
|
+
|
|
298
|
+
it('⌘/Ctrl-wheel zooms at the cursor with the DOM path exp() step', () => {
|
|
299
|
+
const ctrl = at({ ctrlKey: true, deltaY: -100 })
|
|
300
|
+
expect(ctrl).toEqual({ kind: 'zoom', factor: Math.exp(0.2), pivotX: 400 })
|
|
301
|
+
const meta = at({ metaKey: true, deltaY: 100 })
|
|
302
|
+
expect(meta).toEqual({ kind: 'zoom', factor: Math.exp(-0.2), pivotX: 400 })
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('Alt-wheel pans horizontally by deltaY', () => {
|
|
306
|
+
expect(at({ altKey: true, deltaY: 60 })).toEqual({ kind: 'pan', deltaPx: 60 })
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('leaves a plain vertical wheel to the page', () => {
|
|
310
|
+
expect(at({ deltaY: 120 })).toEqual({ kind: 'none' })
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('consumes a horizontally-dominant plain wheel (trackpad swipe)', () => {
|
|
314
|
+
expect(at({ deltaX: 80, deltaY: 5 })).toEqual({ kind: 'pan', deltaPx: 80 })
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('applies an intent to the viewport', () => {
|
|
318
|
+
const v = vp({ pxPerSecond: 20, scrollSeconds: 10, widthPx: 1000 })
|
|
319
|
+
const zoomed = applyWheelIntent(v, { kind: 'zoom', factor: 2, pivotX: 0 }, 600)
|
|
320
|
+
expect(zoomed.pxPerSecond).toBe(40)
|
|
321
|
+
expect(zoomed.scrollSeconds).toBe(10) // pivot at x=0 is the left edge
|
|
322
|
+
expect(applyWheelIntent(v, { kind: 'pan', deltaPx: 200 }, 600).scrollSeconds).toBe(20)
|
|
323
|
+
expect(applyWheelIntent(v, { kind: 'none' }, 600)).toBe(v)
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('surface size changes', () => {
|
|
328
|
+
it('starts fitted on the first layout', () => {
|
|
329
|
+
const first = withSurfaceWidth({ pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }, 800, 40)
|
|
330
|
+
expect(first.widthPx).toBe(800)
|
|
331
|
+
expect(first.pxPerSecond).toBe(20)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('preserves scale when the window is resized', () => {
|
|
335
|
+
const v = vp({ pxPerSecond: 60, scrollSeconds: 5, widthPx: 1000 })
|
|
336
|
+
const wider = withSurfaceWidth(v, 1400, 600)
|
|
337
|
+
expect(wider.pxPerSecond).toBe(60)
|
|
338
|
+
expect(wider.scrollSeconds).toBe(5)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('re-clamps scroll when the project shrinks under the playhead', () => {
|
|
342
|
+
const v = vp({ pxPerSecond: 10, scrollSeconds: 400, widthPx: 1000 })
|
|
343
|
+
const shrunk = reclampForDuration(v, 60)
|
|
344
|
+
expect(shrunk.scrollSeconds).toBe(0)
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('DPR-crisp sizing', () => {
|
|
349
|
+
it('sizes the backing store in device pixels', () => {
|
|
350
|
+
expect(backingStoreSize(800, 120, 2)).toEqual({ width: 1600, height: 240 })
|
|
351
|
+
expect(backingStoreSize(800.4, 120.6, 1)).toEqual({ width: 800, height: 121 })
|
|
352
|
+
expect(backingStoreSize(0, 0, 2)).toEqual({ width: 1, height: 1 })
|
|
353
|
+
expect(backingStoreSize(800, 120, 0)).toEqual({ width: 800, height: 120 })
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('only rewrites canvas dimensions when they actually change', () => {
|
|
357
|
+
const canvas = document.createElement('canvas')
|
|
358
|
+
expect(syncCanvasBackingStore(canvas, { cssWidth: 400, cssHeight: 100, dpr: 2 })).toBe(true)
|
|
359
|
+
expect(canvas.width).toBe(800)
|
|
360
|
+
expect(canvas.height).toBe(200)
|
|
361
|
+
expect(syncCanvasBackingStore(canvas, { cssWidth: 400, cssHeight: 100, dpr: 2 })).toBe(false)
|
|
362
|
+
// Same CSS box, new monitor: the backing store must follow the DPR.
|
|
363
|
+
expect(syncCanvasBackingStore(canvas, { cssWidth: 400, cssHeight: 100, dpr: 1 })).toBe(true)
|
|
364
|
+
expect(canvas.width).toBe(400)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('puts the context in CSS-pixel space idempotently', () => {
|
|
368
|
+
const setTransform = vi.fn()
|
|
369
|
+
scaleContextToDpr({ setTransform } as unknown as CanvasRenderingContext2D, 3)
|
|
370
|
+
scaleContextToDpr({ setTransform } as unknown as CanvasRenderingContext2D, 3)
|
|
371
|
+
expect(setTransform).toHaveBeenNthCalledWith(1, 3, 0, 0, 3, 0, 0)
|
|
372
|
+
expect(setTransform).toHaveBeenNthCalledWith(2, 3, 0, 0, 3, 0, 0)
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
describe('viewport store', () => {
|
|
377
|
+
it('notifies subscribers on change and skips no-op writes', () => {
|
|
378
|
+
const store = createViewportStore(vp())
|
|
379
|
+
const seen = vi.fn()
|
|
380
|
+
const unsubscribe = store.subscribe(seen)
|
|
381
|
+
|
|
382
|
+
store.set(v => ({ ...v, scrollSeconds: 4 }))
|
|
383
|
+
expect(store.get().scrollSeconds).toBe(4)
|
|
384
|
+
expect(seen).toHaveBeenCalledTimes(1)
|
|
385
|
+
|
|
386
|
+
store.set(v => ({ ...v })) // same numbers, new object
|
|
387
|
+
expect(seen).toHaveBeenCalledTimes(1)
|
|
388
|
+
|
|
389
|
+
unsubscribe()
|
|
390
|
+
store.set(v => ({ ...v, scrollSeconds: 9 }))
|
|
391
|
+
expect(seen).toHaveBeenCalledTimes(1)
|
|
392
|
+
expect(store.get().scrollSeconds).toBe(9)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('keeps snapshot identity stable so useSyncExternalStore does not loop', () => {
|
|
396
|
+
const store = createViewportStore(vp())
|
|
397
|
+
expect(store.get()).toBe(store.get())
|
|
398
|
+
})
|
|
399
|
+
})
|