@bycrux/editor 0.12.0 → 1.0.2
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 +161 -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,606 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* SP5 T5 — the pointer wiring, and only the wiring.
|
|
4
|
+
*
|
|
5
|
+
* Every gesture DECISION is covered as pure data in `pointer-machine.test.ts`.
|
|
6
|
+
* What can only be shown with a mounted component is here: that browser events
|
|
7
|
+
* reach the machine in surface coordinates, that its effects land on the right
|
|
8
|
+
* callbacks, that the document-level listeners live exactly as long as the
|
|
9
|
+
* gesture, and that the cursor is written without a React render.
|
|
10
|
+
*
|
|
11
|
+
* jsdom has no 2D canvas and lays everything out at 0×0, so both are stubbed —
|
|
12
|
+
* the same harness T4's suite uses.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
15
|
+
import { render, act, cleanup } from '@testing-library/react'
|
|
16
|
+
import { createPlaybackClock } from '../../../playback-clock'
|
|
17
|
+
import type { Project } from '../../../../types'
|
|
18
|
+
import TimelineCanvas from '../TimelineCanvas'
|
|
19
|
+
import { createViewportStore } from '../viewport'
|
|
20
|
+
import { AUDIO_ITEM_INSET_PX, computeTimelineLayout } from '../draw'
|
|
21
|
+
import { trackItems } from '../../timeline-model'
|
|
22
|
+
|
|
23
|
+
let realGetContext: typeof HTMLCanvasElement.prototype.getContext
|
|
24
|
+
let realGetRect: typeof Element.prototype.getBoundingClientRect
|
|
25
|
+
|
|
26
|
+
function stubContext() {
|
|
27
|
+
return new Proxy({}, {
|
|
28
|
+
get(_t, prop: string) {
|
|
29
|
+
if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
|
|
30
|
+
return () => {}
|
|
31
|
+
},
|
|
32
|
+
set() { return true },
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
realGetContext = HTMLCanvasElement.prototype.getContext
|
|
38
|
+
realGetRect = Element.prototype.getBoundingClientRect
|
|
39
|
+
HTMLCanvasElement.prototype.getContext = (() => stubContext()) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
40
|
+
Element.prototype.getBoundingClientRect = function (this: Element) {
|
|
41
|
+
return { x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 100, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
|
|
42
|
+
}
|
|
43
|
+
vi.useFakeTimers()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
cleanup()
|
|
48
|
+
vi.useRealTimers()
|
|
49
|
+
HTMLCanvasElement.prototype.getContext = realGetContext
|
|
50
|
+
Element.prototype.getBoundingClientRect = realGetRect
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// One base track (the tall row, starting at y 0) and one audio lane below it.
|
|
54
|
+
// Probes into the track row use small Y's, which stay inside it at any row
|
|
55
|
+
// height; the lane's own Y is derived below, since it moves when rows resize.
|
|
56
|
+
const project = {
|
|
57
|
+
id: 'p',
|
|
58
|
+
tracks: [{
|
|
59
|
+
id: 'trk-0',
|
|
60
|
+
items: [
|
|
61
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4, sourceDuration: 20 },
|
|
62
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 4, end: 8, inPoint: 0, outPoint: 4, sourceDuration: 20 },
|
|
63
|
+
],
|
|
64
|
+
}],
|
|
65
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 5, lane: 0 }] },
|
|
66
|
+
} as unknown as Project
|
|
67
|
+
|
|
68
|
+
/** Vertical centre of the audio lane, taken from the painter's own layout. */
|
|
69
|
+
const LANE_Y = (() => {
|
|
70
|
+
const lane = computeTimelineLayout(project).lanes[0]
|
|
71
|
+
return Math.round(lane.y + lane.height / 2)
|
|
72
|
+
})()
|
|
73
|
+
|
|
74
|
+
/** Vertical centre of the single visual row, likewise from the layout — the
|
|
75
|
+
* ruler strip offsets everything below it, so a hardcoded y aims at the gap. */
|
|
76
|
+
const ROW_Y = (() => {
|
|
77
|
+
const row = computeTimelineLayout(project).rows[0]
|
|
78
|
+
return Math.round(row.y + row.height / 2)
|
|
79
|
+
})()
|
|
80
|
+
|
|
81
|
+
/** Inside the ruler strip, where scrubbing lives. */
|
|
82
|
+
const RULER_Y = (() => {
|
|
83
|
+
const ruler = computeTimelineLayout(project).ruler
|
|
84
|
+
return Math.round(ruler.y + ruler.height / 2)
|
|
85
|
+
})()
|
|
86
|
+
|
|
87
|
+
/** Just inside the TOP of the audio lane's bar — where a fade grip's small
|
|
88
|
+
* hit zone lives (see hit-test.ts's `FADE_GRIP_ZONE_HEIGHT_PX`), a few px
|
|
89
|
+
* below the lane's own top-of-bar y so it's inside that zone rather than
|
|
90
|
+
* sitting exactly on its boundary. */
|
|
91
|
+
const LANE_TOP = (() => {
|
|
92
|
+
const lane = computeTimelineLayout(project).lanes[0]
|
|
93
|
+
return Math.round(lane.y + AUDIO_ITEM_INSET_PX) + 2
|
|
94
|
+
})()
|
|
95
|
+
|
|
96
|
+
const TOTAL_DURATION = 13
|
|
97
|
+
const SNAP_BOUNDARIES = [0, 4, 8, 1, 5]
|
|
98
|
+
const FPS = 30
|
|
99
|
+
|
|
100
|
+
/** The same fixture with a caption band, for the caption-only wiring below.
|
|
101
|
+
* Separate so the row/lane probes above keep the geometry they assume. */
|
|
102
|
+
const captionedProject = {
|
|
103
|
+
...project,
|
|
104
|
+
captions: { style: 'pop', segments: [{ id: 's0', text: 'hello', start: 1, end: 3 }] },
|
|
105
|
+
} as unknown as Project
|
|
106
|
+
|
|
107
|
+
/** Vertical centre of the caption band, from the painter's own layout. */
|
|
108
|
+
const CAPTION_Y = (() => {
|
|
109
|
+
const band = computeTimelineLayout(captionedProject).captions![0]
|
|
110
|
+
return Math.round(band.y + band.height / 2)
|
|
111
|
+
})()
|
|
112
|
+
|
|
113
|
+
function mount(overrides: Partial<React.ComponentProps<typeof TimelineCanvas>> = {}) {
|
|
114
|
+
const store = createViewportStore()
|
|
115
|
+
const clock = createPlaybackClock()
|
|
116
|
+
const handlers = {
|
|
117
|
+
onSelectItem: vi.fn(),
|
|
118
|
+
onSelectItems: vi.fn(),
|
|
119
|
+
onProjectChange: vi.fn(),
|
|
120
|
+
onOverlayEdit: vi.fn(),
|
|
121
|
+
onInspectClip: vi.fn(),
|
|
122
|
+
onInspectAudio: vi.fn(),
|
|
123
|
+
onHoverScrub: vi.fn(),
|
|
124
|
+
}
|
|
125
|
+
const renders = vi.fn()
|
|
126
|
+
function Sibling() { renders(); return null }
|
|
127
|
+
|
|
128
|
+
// Wrapped in a component so a test can flip `previewAxis` after mounting
|
|
129
|
+
// without rebuilding the store, the clock or the spies.
|
|
130
|
+
function Surface({ axis }: { axis?: boolean }) {
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
<TimelineCanvas
|
|
134
|
+
project={project}
|
|
135
|
+
clock={clock}
|
|
136
|
+
store={store}
|
|
137
|
+
totalDuration={TOTAL_DURATION}
|
|
138
|
+
fps={FPS}
|
|
139
|
+
selectedIds={[]}
|
|
140
|
+
snapBoundaries={SNAP_BOUNDARIES}
|
|
141
|
+
{...handlers}
|
|
142
|
+
{...overrides}
|
|
143
|
+
{...(axis === undefined ? {} : { previewAxis: axis })}
|
|
144
|
+
/>
|
|
145
|
+
<Sibling />
|
|
146
|
+
</>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const utils = render(<Surface />)
|
|
151
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
152
|
+
// Pin the scale so the assertions can talk in whole seconds: x = t × 100.
|
|
153
|
+
act(() => { store.set({ pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }) })
|
|
154
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
...utils,
|
|
158
|
+
store,
|
|
159
|
+
clock,
|
|
160
|
+
renders,
|
|
161
|
+
...handlers,
|
|
162
|
+
rerenderWithAxis: (axis: boolean) => utils.rerender(<Surface axis={axis} />),
|
|
163
|
+
surface: utils.container.querySelector('[data-timeline-canvas]') as HTMLElement,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function mouse(type: string, x: number, y: number, init: MouseEventInit = {}) {
|
|
168
|
+
return new MouseEvent(type, { clientX: x, clientY: y, bubbles: true, cancelable: true, button: 0, ...init })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe('TimelineCanvas — pointer wiring', () => {
|
|
172
|
+
it('seeks the clock from a click on empty timeline', () => {
|
|
173
|
+
// On release, not on press: the same press may turn out to be a marquee.
|
|
174
|
+
const { surface, clock } = mount()
|
|
175
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y)) })
|
|
176
|
+
expect(clock.get()).toBe(0)
|
|
177
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 900, ROW_Y)) })
|
|
178
|
+
expect(clock.get()).toBeCloseTo(9)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('seeks on PRESS from the ruler, and keeps scrubbing', () => {
|
|
182
|
+
const { surface, clock } = mount()
|
|
183
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 900, RULER_Y)) })
|
|
184
|
+
expect(clock.get()).toBeCloseTo(9)
|
|
185
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 600, RULER_Y)) })
|
|
186
|
+
expect(clock.get()).toBeCloseTo(6)
|
|
187
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 600, RULER_Y)) })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('drags a marquee across empty track area and selects what it caught', () => {
|
|
191
|
+
const { surface, onSelectItems } = mount()
|
|
192
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y)) })
|
|
193
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 300, ROW_Y)) })
|
|
194
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 300, ROW_Y)) })
|
|
195
|
+
expect(onSelectItems).toHaveBeenCalledTimes(1)
|
|
196
|
+
const [ids, additive] = onSelectItems.mock.calls[0]
|
|
197
|
+
expect(ids).toEqual(expect.arrayContaining(['c1']))
|
|
198
|
+
expect(additive).toBe(false)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('converts client coordinates into surface coordinates', () => {
|
|
202
|
+
// The stubbed rect is at the origin, so shift it and check the seek moves.
|
|
203
|
+
Element.prototype.getBoundingClientRect = function (this: Element) {
|
|
204
|
+
return { x: 200, y: 50, top: 50, left: 200, right: 1200, bottom: 150, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
|
|
205
|
+
}
|
|
206
|
+
const { surface, clock } = mount()
|
|
207
|
+
// Client x=1100 is surface x=900 — empty track area at t=9. The y is the
|
|
208
|
+
// row's own centre pushed down by the stubbed rect's top edge.
|
|
209
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 1100, 50 + ROW_Y)) })
|
|
210
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 1100, 50 + ROW_Y)) })
|
|
211
|
+
expect(clock.get()).toBeCloseTo(9)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('selects a clip on a click, through the host\'s own handler', () => {
|
|
215
|
+
const { surface, onSelectItem, onProjectChange } = mount()
|
|
216
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
|
|
217
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 200, ROW_Y)) })
|
|
218
|
+
expect(onSelectItem).toHaveBeenCalledWith('c0', false)
|
|
219
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('passes the additive modifier through', () => {
|
|
223
|
+
const { surface, onSelectItem } = mount()
|
|
224
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y, { shiftKey: true })) })
|
|
225
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 200, ROW_Y, { shiftKey: true })) })
|
|
226
|
+
expect(onSelectItem).toHaveBeenCalledWith('c0', true)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('drives a drag through onProjectChange per move and onOverlayEdit once', () => {
|
|
230
|
+
const { surface, onProjectChange, onOverlayEdit, onSelectItem } = mount()
|
|
231
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
|
|
232
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
|
|
233
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 300, ROW_Y)) })
|
|
234
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 300, ROW_Y)) })
|
|
235
|
+
|
|
236
|
+
expect(onProjectChange).toHaveBeenCalledTimes(2)
|
|
237
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
238
|
+
expect(onSelectItem).not.toHaveBeenCalled()
|
|
239
|
+
|
|
240
|
+
const committed = onOverlayEdit.mock.calls[0][0] as Project
|
|
241
|
+
expect(trackItems(committed).flat().find(i => i.id === 'c0')).toMatchObject({ start: 1, end: 5 })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('trims from a press on the out handle', () => {
|
|
245
|
+
const { surface, onProjectChange } = mount()
|
|
246
|
+
// c0 ends at t=4 → x=400; its out handle is 390–400.
|
|
247
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 396, ROW_Y)) })
|
|
248
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 296, ROW_Y)) })
|
|
249
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 296, ROW_Y)) })
|
|
250
|
+
|
|
251
|
+
const edited = onProjectChange.mock.calls[0][0] as Project
|
|
252
|
+
expect(trackItems(edited).flat().find(i => i.id === 'c0')).toMatchObject({ end: 3, outPoint: 3 })
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('does nothing on a double-click over empty timeline', () => {
|
|
256
|
+
const { surface, onProjectChange, onSelectItem, onInspectClip } = mount()
|
|
257
|
+
act(() => { surface.dispatchEvent(mouse('dblclick', 900, ROW_Y)) })
|
|
258
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
259
|
+
expect(onSelectItem).not.toHaveBeenCalled()
|
|
260
|
+
expect(onInspectClip).not.toHaveBeenCalled()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('opens the inspectors on a double-click over an item', () => {
|
|
264
|
+
const { surface, onInspectClip, onInspectAudio } = mount()
|
|
265
|
+
act(() => { surface.dispatchEvent(mouse('dblclick', 200, ROW_Y)) })
|
|
266
|
+
expect(onInspectClip).toHaveBeenCalledWith('c0')
|
|
267
|
+
act(() => { surface.dispatchEvent(mouse('dblclick', 300, LANE_Y)) })
|
|
268
|
+
expect(onInspectAudio).toHaveBeenCalledWith('a0')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('writes the cursor straight to the node, without a React render', () => {
|
|
272
|
+
const { surface, renders } = mount()
|
|
273
|
+
const before = renders.mock.calls.length
|
|
274
|
+
|
|
275
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 200, ROW_Y)) })
|
|
276
|
+
expect(surface.style.cursor).toBe('grab')
|
|
277
|
+
|
|
278
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 396, ROW_Y)) })
|
|
279
|
+
expect(surface.style.cursor).toBe('ew-resize')
|
|
280
|
+
|
|
281
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 900, ROW_Y)) })
|
|
282
|
+
// Empty track area is the plain arrow: nothing to click, only a marquee
|
|
283
|
+
// to drag out.
|
|
284
|
+
expect(surface.style.cursor).toBe('default')
|
|
285
|
+
|
|
286
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 900, RULER_Y)) })
|
|
287
|
+
expect(surface.style.cursor).toBe('ew-resize')
|
|
288
|
+
|
|
289
|
+
expect(renders.mock.calls.length).toBe(before)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('keeps the document listeners only for the length of a gesture', () => {
|
|
293
|
+
const { surface, clock, onProjectChange } = mount()
|
|
294
|
+
|
|
295
|
+
// No press: page-wide movement is ignored.
|
|
296
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
|
|
297
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
298
|
+
|
|
299
|
+
// Driven from the ruler, which is where a drag still moves the clock.
|
|
300
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 900, RULER_Y)) })
|
|
301
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 950, RULER_Y)) })
|
|
302
|
+
expect(clock.get()).toBeCloseTo(9.5)
|
|
303
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 950, RULER_Y)) })
|
|
304
|
+
|
|
305
|
+
// After release, movement over the page no longer scrubs.
|
|
306
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 100, ROW_Y)) })
|
|
307
|
+
expect(clock.get()).toBeCloseTo(9.5)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('ignores non-primary buttons', () => {
|
|
311
|
+
const { surface, clock } = mount()
|
|
312
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y, { button: 2 })) })
|
|
313
|
+
expect(clock.get()).toBe(0)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('swallows the click so the DOM chrome above cannot seek a second time', () => {
|
|
317
|
+
// Timeline's own container click seeks by percentage of totalDuration,
|
|
318
|
+
// which is not the canvas' time axis. The press has already seeked.
|
|
319
|
+
const onOuterClick = vi.fn()
|
|
320
|
+
const { surface } = mount()
|
|
321
|
+
document.addEventListener('click', onOuterClick)
|
|
322
|
+
try {
|
|
323
|
+
act(() => { surface.dispatchEvent(mouse('click', 900, 20)) })
|
|
324
|
+
expect(onOuterClick).not.toHaveBeenCalled()
|
|
325
|
+
} finally {
|
|
326
|
+
document.removeEventListener('click', onOuterClick)
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('unmounts without leaving document listeners behind', () => {
|
|
331
|
+
const { surface, clock, unmount } = mount()
|
|
332
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y)) })
|
|
333
|
+
const seeked = clock.get()
|
|
334
|
+
unmount()
|
|
335
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 100, ROW_Y)) })
|
|
336
|
+
expect(clock.get()).toBe(seeked)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('freezes the surface rect for a gesture, so a mid-drag reflow cannot shift its own points', () => {
|
|
340
|
+
// Simulates the real bug: the drag's own `projectChange` echo adds a new
|
|
341
|
+
// track row, which reflows the page and shifts the container's rect
|
|
342
|
+
// (here, up by one row's worth — 44px — via scroll anchoring). A live
|
|
343
|
+
// re-read of the rect on every move would make the SAME physical cursor
|
|
344
|
+
// travel compute a different surface delta partway through the gesture.
|
|
345
|
+
const { surface, onOverlayEdit } = mount()
|
|
346
|
+
|
|
347
|
+
// Rect at press: top 0. Rect for every read after that: top -44. Installed
|
|
348
|
+
// only now, after `mount()` — mounting itself does one rect read (surface
|
|
349
|
+
// setup) that must not consume the "at mousedown" slot below.
|
|
350
|
+
let reads = 0
|
|
351
|
+
Element.prototype.getBoundingClientRect = function (this: Element) {
|
|
352
|
+
reads += 1
|
|
353
|
+
const top = reads === 1 ? 0 : -44
|
|
354
|
+
return { x: 0, y: top, top, left: 0, right: 1000, bottom: top + 100, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// c0 is on the sole video track (row y 0–56). Press its body, then drag
|
|
358
|
+
// straight up 44px in PAGE coordinates — one row's worth — well past the
|
|
359
|
+
// drag threshold, to a new top track.
|
|
360
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
|
|
361
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 200, 4)) })
|
|
362
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 200, -24)) })
|
|
363
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 200, -24)) })
|
|
364
|
+
|
|
365
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
366
|
+
const committed = onOverlayEdit.mock.calls[0][0] as Project
|
|
367
|
+
const tracks = committed.tracks ?? []
|
|
368
|
+
// The true 44px upward travel must land c0 on a NEW track above its
|
|
369
|
+
// source track, not undo the drag back onto the track it started on.
|
|
370
|
+
expect(tracks).toHaveLength(2)
|
|
371
|
+
expect(tracks[0].items.some(i => i.id === 'c0')).toBe(false)
|
|
372
|
+
expect(tracks[1].items.some(i => i.id === 'c0')).toBe(true)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('does nothing when the host supplies no edit callbacks', () => {
|
|
376
|
+
const { surface } = mount({ onProjectChange: undefined, onOverlayEdit: undefined, onSelectItem: undefined })
|
|
377
|
+
expect(() => {
|
|
378
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
|
|
379
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 300, ROW_Y)) })
|
|
380
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 300, ROW_Y)) })
|
|
381
|
+
}).not.toThrow()
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// ── Preview axis ─────────────────────────────────────────────────────────
|
|
386
|
+
//
|
|
387
|
+
// Off (the default) this surface behaves exactly as every case above shows —
|
|
388
|
+
// clicks seek, nothing hovers. On, moving the pointer reports the time under it
|
|
389
|
+
// so the host can preview that frame, WITHOUT touching the playback clock.
|
|
390
|
+
|
|
391
|
+
describe('TimelineCanvas — preview axis', () => {
|
|
392
|
+
it('reports nothing on hover while the axis is off', () => {
|
|
393
|
+
const { surface, clock, onHoverScrub } = mount()
|
|
394
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
|
|
395
|
+
expect(onHoverScrub).not.toHaveBeenCalled()
|
|
396
|
+
expect(clock.get()).toBe(0)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('reports the hovered time while the axis is on, without moving the clock', () => {
|
|
400
|
+
const { surface, clock, onHoverScrub } = mount({ previewAxis: true })
|
|
401
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
|
|
402
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
403
|
+
expect(onHoverScrub).toHaveBeenCalledWith(5)
|
|
404
|
+
expect(clock.get()).toBe(0)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('tracks the pointer across the surface, one frame at a time', () => {
|
|
408
|
+
const { surface, clock, onHoverScrub } = mount({ previewAxis: true })
|
|
409
|
+
for (const x of [200, 640, 900]) {
|
|
410
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', x, 20)) })
|
|
411
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
412
|
+
}
|
|
413
|
+
expect(onHoverScrub.mock.calls.map(c => c[0])).toEqual([2, 6.4, 9])
|
|
414
|
+
expect(clock.get()).toBe(0)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('coalesces a burst of moves into ONE request, for the latest position', () => {
|
|
418
|
+
// A trackpad sweep delivers far more moves than the display can show, and
|
|
419
|
+
// on long-GOP media each seek cancels the decode still in flight — the
|
|
420
|
+
// reason a fast scrub flashes black. Intermediate positions are dropped,
|
|
421
|
+
// not queued, so the request is always where the pointer is NOW.
|
|
422
|
+
const { surface, onHoverScrub } = mount({ previewAxis: true })
|
|
423
|
+
act(() => {
|
|
424
|
+
for (const x of [100, 200, 300, 400, 500, 600, 700]) {
|
|
425
|
+
surface.dispatchEvent(mouse('mousemove', x, 20))
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
429
|
+
expect(onHoverScrub.mock.calls.map(c => c[0])).toEqual([7])
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('draws the cursor line on every move, even the coalesced ones', () => {
|
|
433
|
+
// Only the frame REQUEST is rate-limited. The line itself must stay glued
|
|
434
|
+
// to the pointer or the affordance feels broken.
|
|
435
|
+
const { surface } = mount({ previewAxis: true })
|
|
436
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 300, ROW_Y)) })
|
|
437
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 800, ROW_Y)) })
|
|
438
|
+
// No assertion on emissions here — TimelineCanvas.test.tsx owns the paint
|
|
439
|
+
// proof; this pins that a burst never throws or drops the redraw path.
|
|
440
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('drops a queued request when the pointer leaves before it fires', () => {
|
|
444
|
+
// Otherwise a stale position lands one frame after the release and pins the
|
|
445
|
+
// preview to a frame the pointer has already left.
|
|
446
|
+
const { surface, onHoverScrub } = mount({ previewAxis: true })
|
|
447
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
|
|
448
|
+
act(() => { surface.dispatchEvent(mouse('mouseleave', 500, 20)) })
|
|
449
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
450
|
+
expect(onHoverScrub.mock.calls.map(c => c[0])).toEqual([null])
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('hovers over clips too, not just bare track area', () => {
|
|
454
|
+
// The pointer is over clip c0's body (row y 0-56); the preview should follow
|
|
455
|
+
// it there exactly as over empty space.
|
|
456
|
+
const { surface, onHoverScrub } = mount({ previewAxis: true })
|
|
457
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
|
|
458
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
459
|
+
expect(onHoverScrub).toHaveBeenCalledWith(2.5)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('releases the override when the pointer leaves the surface', () => {
|
|
463
|
+
const { surface, onHoverScrub } = mount({ previewAxis: true })
|
|
464
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
|
|
465
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
466
|
+
onHoverScrub.mockClear()
|
|
467
|
+
act(() => { surface.dispatchEvent(mouse('mouseleave', 500, 20)) })
|
|
468
|
+
expect(onHoverScrub).toHaveBeenCalledWith(null)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('releases the override when a gesture starts, and the click still seeks', () => {
|
|
472
|
+
// A press hands the playhead to the gesture; leaving a hover override up
|
|
473
|
+
// would pin the preview to a frame the drag is moving away from.
|
|
474
|
+
const { surface, clock, onHoverScrub } = mount({ previewAxis: true })
|
|
475
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
|
|
476
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
477
|
+
onHoverScrub.mockClear()
|
|
478
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y)) })
|
|
479
|
+
expect(onHoverScrub).toHaveBeenCalledWith(null)
|
|
480
|
+
// The seek itself lands on release now — empty space defers, because the
|
|
481
|
+
// press may still turn into a marquee.
|
|
482
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 900, ROW_Y)) })
|
|
483
|
+
expect(clock.get()).toBeCloseTo(9)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('clicking a clip seeks with the axis on, exactly as with it off', () => {
|
|
487
|
+
const { surface, clock, onSelectItem } = mount({ previewAxis: true })
|
|
488
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
|
|
489
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 200, ROW_Y)) })
|
|
490
|
+
expect(onSelectItem).toHaveBeenCalledWith('c0', false)
|
|
491
|
+
expect(clock.get()).toBeCloseTo(2)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('routes a caption double-click to onEditCaption, not to an inspector', () => {
|
|
495
|
+
const onEditCaption = vi.fn()
|
|
496
|
+
const { surface, onInspectClip, onInspectAudio } = mount({ project: captionedProject, onEditCaption })
|
|
497
|
+
act(() => { surface.dispatchEvent(mouse('dblclick', 200, CAPTION_Y)) })
|
|
498
|
+
expect(onEditCaption).toHaveBeenCalledWith('s0')
|
|
499
|
+
expect(onInspectClip).not.toHaveBeenCalled()
|
|
500
|
+
expect(onInspectAudio).not.toHaveBeenCalled()
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('releases the override when the axis is switched off mid-hover', () => {
|
|
504
|
+
// No further mousemove is coming to do it, so the preview would otherwise
|
|
505
|
+
// stay frozen on whatever frame the pointer last rested over.
|
|
506
|
+
const { surface, onHoverScrub, rerenderWithAxis } = mount({ previewAxis: true })
|
|
507
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
|
|
508
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
509
|
+
onHoverScrub.mockClear()
|
|
510
|
+
act(() => { rerenderWithAxis(false) })
|
|
511
|
+
expect(onHoverScrub).toHaveBeenCalledWith(null)
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
describe('TimelineCanvas — fade-curve context menu', () => {
|
|
516
|
+
it('right-clicking a fade grip calls onFadeCurveMenu with the track, side, and CLIENT coordinates', () => {
|
|
517
|
+
const onFadeCurveMenu = vi.fn()
|
|
518
|
+
const { surface } = mount({ onFadeCurveMenu })
|
|
519
|
+
// a0 runs 1..5s at 100px/s → its fade-in grip sits at its own start
|
|
520
|
+
// corner (x=100) when no fade is set (see hit-test.ts's `audioFadeGripX`).
|
|
521
|
+
act(() => { surface.dispatchEvent(mouse('contextmenu', 100, LANE_TOP)) })
|
|
522
|
+
expect(onFadeCurveMenu).toHaveBeenCalledTimes(1)
|
|
523
|
+
expect(onFadeCurveMenu).toHaveBeenCalledWith({ trackId: 'a0', side: 'in', x: 100, y: LANE_TOP })
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('right-clicking the OUT grip reports side "out"', () => {
|
|
527
|
+
const onFadeCurveMenu = vi.fn()
|
|
528
|
+
const { surface } = mount({ onFadeCurveMenu })
|
|
529
|
+
// a0 ends at t=5 → x=500.
|
|
530
|
+
act(() => { surface.dispatchEvent(mouse('contextmenu', 500, LANE_TOP)) })
|
|
531
|
+
expect(onFadeCurveMenu).toHaveBeenCalledWith({ trackId: 'a0', side: 'out', x: 500, y: LANE_TOP })
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('prevents the default browser menu only on a fade-grip hit', () => {
|
|
535
|
+
const onFadeCurveMenu = vi.fn()
|
|
536
|
+
const { surface } = mount({ onFadeCurveMenu })
|
|
537
|
+
const onGrip = mouse('contextmenu', 100, LANE_TOP)
|
|
538
|
+
surface.dispatchEvent(onGrip)
|
|
539
|
+
expect(onGrip.defaultPrevented).toBe(true)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('does nothing on a right-click away from a fade grip — no menu, native default untouched', () => {
|
|
543
|
+
const onFadeCurveMenu = vi.fn()
|
|
544
|
+
const { surface } = mount({ onFadeCurveMenu })
|
|
545
|
+
const elsewhere = mouse('contextmenu', 300, LANE_TOP)
|
|
546
|
+
surface.dispatchEvent(elsewhere)
|
|
547
|
+
expect(onFadeCurveMenu).not.toHaveBeenCalled()
|
|
548
|
+
expect(elsewhere.defaultPrevented).toBe(false)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('does nothing when the host implements no onFadeCurveMenu at all', () => {
|
|
552
|
+
const { surface } = mount()
|
|
553
|
+
// Must not throw — a host that predates the picker gets exactly the old
|
|
554
|
+
// browser context menu everywhere on the surface.
|
|
555
|
+
expect(() => { surface.dispatchEvent(mouse('contextmenu', 100, LANE_TOP)) }).not.toThrow()
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
describe('TimelineCanvas — keyframe-strip context menu (SP9b T3.3)', () => {
|
|
560
|
+
// A single overlay track, keyframed on offsetX at t=0.5 only — the strip's
|
|
561
|
+
// one diamond, at x=(2+0.5)×100=250 at the scale `mount` pins.
|
|
562
|
+
const keyframedProject = {
|
|
563
|
+
id: 'p',
|
|
564
|
+
tracks: [{
|
|
565
|
+
id: 'trk-0',
|
|
566
|
+
items: [{
|
|
567
|
+
id: 'o0', type: 'overlay', start: 2, end: 4,
|
|
568
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }] }],
|
|
569
|
+
}],
|
|
570
|
+
}],
|
|
571
|
+
} as unknown as Project
|
|
572
|
+
|
|
573
|
+
const KF_ROW = computeTimelineLayout(keyframedProject).rows[0]
|
|
574
|
+
const KF_ZONE_Y = Math.round(KF_ROW.y + KF_ROW.height - 2)
|
|
575
|
+
const KF_X = 250
|
|
576
|
+
|
|
577
|
+
it('right-clicking a diamond calls onKeyframeMenu with the item, t, props and CLIENT coordinates', () => {
|
|
578
|
+
const onKeyframeMenu = vi.fn()
|
|
579
|
+
const { surface } = mount({ project: keyframedProject, selectedIds: ['o0'], onKeyframeMenu })
|
|
580
|
+
act(() => { surface.dispatchEvent(mouse('contextmenu', KF_X, KF_ZONE_Y)) })
|
|
581
|
+
expect(onKeyframeMenu).toHaveBeenCalledTimes(1)
|
|
582
|
+
expect(onKeyframeMenu).toHaveBeenCalledWith({ itemId: 'o0', t: 0.5, props: ['offsetX'], isLast: true, x: KF_X, y: KF_ZONE_Y })
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('prevents the default browser menu only on a diamond hit', () => {
|
|
586
|
+
const onKeyframeMenu = vi.fn()
|
|
587
|
+
const { surface } = mount({ project: keyframedProject, selectedIds: ['o0'], onKeyframeMenu })
|
|
588
|
+
const onDiamond = mouse('contextmenu', KF_X, KF_ZONE_Y)
|
|
589
|
+
surface.dispatchEvent(onDiamond)
|
|
590
|
+
expect(onDiamond.defaultPrevented).toBe(true)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('does not open when the keyframed overlay is not selected', () => {
|
|
594
|
+
const onKeyframeMenu = vi.fn()
|
|
595
|
+
const { surface } = mount({ project: keyframedProject, selectedIds: [], onKeyframeMenu })
|
|
596
|
+
const elsewhere = mouse('contextmenu', KF_X, KF_ZONE_Y)
|
|
597
|
+
surface.dispatchEvent(elsewhere)
|
|
598
|
+
expect(onKeyframeMenu).not.toHaveBeenCalled()
|
|
599
|
+
expect(elsewhere.defaultPrevented).toBe(false)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('does nothing when the host implements no onKeyframeMenu at all', () => {
|
|
603
|
+
const { surface } = mount({ project: keyframedProject, selectedIds: ['o0'] })
|
|
604
|
+
expect(() => { surface.dispatchEvent(mouse('contextmenu', KF_X, KF_ZONE_Y)) }).not.toThrow()
|
|
605
|
+
})
|
|
606
|
+
})
|