@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,372 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The left rail that names each track. Its whole job is alignment: a label that
|
|
4
|
+
* sits at a different y than the clips it describes is worse than no label, so
|
|
5
|
+
* these assert it positions off the SAME layout the canvas paints from.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
8
|
+
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
|
|
9
|
+
import TrackGutter, { visualTrackKind } from '../TrackGutter'
|
|
10
|
+
import { computeTimelineLayout } from '../canvas/draw'
|
|
11
|
+
import type { Project } from '../../../types'
|
|
12
|
+
import type { Captions, VisualItem } from '../../../schema'
|
|
13
|
+
|
|
14
|
+
afterEach(() => cleanup())
|
|
15
|
+
|
|
16
|
+
const video = (id: string, over: Partial<VisualItem> = {}): VisualItem =>
|
|
17
|
+
({ id, type: 'video', src: 'a.mp4', start: 0, end: 2, ...over }) as VisualItem
|
|
18
|
+
const overlay = (id: string, over: Partial<VisualItem> = {}): VisualItem =>
|
|
19
|
+
({ id, type: 'overlay', src: '/p/overlays/text_line.jsx', start: 0, end: 2, ...over }) as VisualItem
|
|
20
|
+
const captions = (over: Partial<Captions> = {}): Captions =>
|
|
21
|
+
({ style: 'clean', segments: [{ text: 'hello', start: 0, end: 1 }], ...over })
|
|
22
|
+
|
|
23
|
+
function project(over: Partial<Project> = {}): Project {
|
|
24
|
+
return {
|
|
25
|
+
id: 'p',
|
|
26
|
+
tracks: [[video('c0')], [overlay('o0')]],
|
|
27
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0 }] },
|
|
28
|
+
...over,
|
|
29
|
+
} as unknown as Project
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('visualTrackKind', () => {
|
|
33
|
+
it('reads the kind off the items, since tracks are untyped in the schema', () => {
|
|
34
|
+
expect(visualTrackKind([video('a'), video('b')])).toBe('video')
|
|
35
|
+
expect(visualTrackKind([overlay('a')])).toBe('overlay')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('has no answer for an empty track rather than guessing "video"', () => {
|
|
39
|
+
expect(visualTrackKind([])).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('falls back to the first item on a mixed track', () => {
|
|
43
|
+
expect(visualTrackKind([overlay('a'), video('b')])).toBe('overlay')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('TrackGutter', () => {
|
|
48
|
+
it('names each row by what it holds, plus the audio lane and the caption row', () => {
|
|
49
|
+
// Icon-only cells: the name is the accessible name (and the hover tooltip),
|
|
50
|
+
// not on-screen text. The caption cell only renders when the layout has a
|
|
51
|
+
// band to align with (see 'TrackGutter — caption cell alignment' below),
|
|
52
|
+
// so this project needs actual caption segments.
|
|
53
|
+
render(<TrackGutter project={project({ captions: captions() })} />)
|
|
54
|
+
expect(screen.getByLabelText('Video')).toBeTruthy()
|
|
55
|
+
expect(screen.getByLabelText('Overlay')).toBeTruthy()
|
|
56
|
+
expect(screen.getByLabelText('Audio')).toBeTruthy()
|
|
57
|
+
expect(screen.getByLabelText('Captions')).toBeTruthy()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('places every label at the y its own row occupies in the canvas layout', () => {
|
|
61
|
+
// The alignment invariant. Both surfaces read one `computeTimelineLayout`,
|
|
62
|
+
// so this fails the moment the rail starts deriving geometry of its own.
|
|
63
|
+
const p = project()
|
|
64
|
+
const layout = computeTimelineLayout(p)
|
|
65
|
+
const { container } = render(<TrackGutter project={p} layout={layout} />)
|
|
66
|
+
|
|
67
|
+
const positioned = [...container.querySelectorAll<HTMLElement>('.absolute')]
|
|
68
|
+
const seen = positioned.map(el => ({ top: el.style.top, height: el.style.height }))
|
|
69
|
+
|
|
70
|
+
for (const row of layout.rows) {
|
|
71
|
+
expect(seen).toContainEqual({ top: `${row.y}px`, height: `${row.height}px` })
|
|
72
|
+
}
|
|
73
|
+
for (const lane of layout.lanes) {
|
|
74
|
+
expect(seen).toContainEqual({ top: `${lane.y}px`, height: `${lane.height}px` })
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('gives the base track its taller cell, not a uniform row height', () => {
|
|
79
|
+
const p = project()
|
|
80
|
+
const layout = computeTimelineLayout(p)
|
|
81
|
+
render(<TrackGutter project={p} layout={layout} />)
|
|
82
|
+
|
|
83
|
+
const base = layout.rows.find(r => r.trackIdx === 0)!
|
|
84
|
+
const overlayRow = layout.rows.find(r => r.trackIdx === 1)!
|
|
85
|
+
expect(base.height).toBeGreaterThan(overlayRow.height)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('omits the caption cell when showCaptionRow is false, even though the layout carries a caption band', () => {
|
|
89
|
+
const p = project({ captions: captions() })
|
|
90
|
+
expect(computeTimelineLayout(p).captions).toBeTruthy()
|
|
91
|
+
render(<TrackGutter project={p} showCaptionRow={false} />)
|
|
92
|
+
expect(screen.queryByLabelText('Captions')).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('labels an empty track rather than rendering a blank cell', () => {
|
|
96
|
+
render(<TrackGutter project={project({ tracks: [{ id: 'trk-0', items: [] }] } as Partial<Project>)} />)
|
|
97
|
+
expect(screen.getByLabelText('Track')).toBeTruthy()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('renders without an audio lane or captions on a bare project', () => {
|
|
101
|
+
const bare = { id: 'p', tracks: [[video('c0')]] } as unknown as Project
|
|
102
|
+
render(<TrackGutter project={bare} showCaptionRow={false} />)
|
|
103
|
+
expect(screen.getByLabelText('Video')).toBeTruthy()
|
|
104
|
+
expect(screen.queryByLabelText('Audio')).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('TrackGutter — caption cell alignment', () => {
|
|
109
|
+
// The caption row moved from a DOM strip below the canvas to a band INSIDE
|
|
110
|
+
// the canvas layout, so its rail cell has to be positioned like every other
|
|
111
|
+
// row rather than trail the gutter as its own flex cell.
|
|
112
|
+
|
|
113
|
+
it('positions the caption cell at layout.captions[0].y/height, inside the same block the track rows live in', () => {
|
|
114
|
+
const p = project({ captions: captions() })
|
|
115
|
+
const layout = computeTimelineLayout(p)
|
|
116
|
+
expect(layout.captions).toHaveLength(1)
|
|
117
|
+
|
|
118
|
+
const { container } = render(<TrackGutter project={p} layout={layout} showCaptionRow />)
|
|
119
|
+
|
|
120
|
+
const cell = screen.getByLabelText('Captions')
|
|
121
|
+
// `.absolute` is the wrapper VisualTrackRailRow/AudioLaneRailRow both use —
|
|
122
|
+
// finding it via `closest` rather than asserting on RailCell itself proves
|
|
123
|
+
// the cell is wrapped the SAME way the other rows are, not styled directly.
|
|
124
|
+
const wrapper = cell.closest('.absolute') as HTMLElement
|
|
125
|
+
expect(wrapper).toBeTruthy()
|
|
126
|
+
expect(wrapper.style.top).toBe(`${layout.captions![0].y}px`)
|
|
127
|
+
expect(wrapper.style.height).toBe(`${layout.captions![0].height}px`)
|
|
128
|
+
|
|
129
|
+
// Structural, not a snapshot: the wrapper is a DESCENDANT of the `.relative`
|
|
130
|
+
// block the track rows and audio lanes are absolutely positioned inside —
|
|
131
|
+
// not a trailing sibling after it.
|
|
132
|
+
const relativeBlock = container.querySelector('.relative')!
|
|
133
|
+
expect(relativeBlock.contains(wrapper)).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('renders no caption cell on a project with no captions, even though showCaptionRow defaults true, and nothing else shifts', () => {
|
|
137
|
+
const p = project() // no `captions` field — the layout carries no bands at all
|
|
138
|
+
const layout = computeTimelineLayout(p)
|
|
139
|
+
expect(layout.captions).toBeUndefined()
|
|
140
|
+
|
|
141
|
+
const { container } = render(<TrackGutter project={p} layout={layout} />)
|
|
142
|
+
expect(screen.queryByLabelText('Captions')).toBeNull()
|
|
143
|
+
|
|
144
|
+
// The row/lane cells still land exactly where the (caption-less) layout
|
|
145
|
+
// says they do — missing bands don't leave a gap or shift anything.
|
|
146
|
+
const positioned = [...container.querySelectorAll<HTMLElement>('.absolute')]
|
|
147
|
+
const seen = positioned.map(el => ({ top: el.style.top, height: el.style.height }))
|
|
148
|
+
for (const row of layout.rows) {
|
|
149
|
+
expect(seen).toContainEqual({ top: `${row.y}px`, height: `${row.height}px` })
|
|
150
|
+
}
|
|
151
|
+
for (const lane of layout.lanes) {
|
|
152
|
+
expect(seen).toContainEqual({ top: `${lane.y}px`, height: `${lane.height}px` })
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('renders N cells, one per band, each at its own band y — "Captions 1"…"Captions N" once there is more than one', () => {
|
|
157
|
+
const p = project({
|
|
158
|
+
captions: captions({
|
|
159
|
+
segments: [
|
|
160
|
+
{ text: 'a', start: 0, end: 1, lane: 0 },
|
|
161
|
+
{ text: 'b', start: 0, end: 1, lane: 1 },
|
|
162
|
+
{ text: 'c', start: 0, end: 1, lane: 2 },
|
|
163
|
+
],
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
166
|
+
const layout = computeTimelineLayout(p)
|
|
167
|
+
expect(layout.captions).toHaveLength(3)
|
|
168
|
+
|
|
169
|
+
render(<TrackGutter project={p} layout={layout} showCaptionRow />)
|
|
170
|
+
|
|
171
|
+
// The bare "Captions" label is gone once there is more than one band.
|
|
172
|
+
expect(screen.queryByLabelText('Captions')).toBeNull()
|
|
173
|
+
for (const band of layout.captions!) {
|
|
174
|
+
const cell = screen.getByLabelText(`Captions ${band.lane + 1}`)
|
|
175
|
+
const wrapper = cell.closest('.absolute') as HTMLElement
|
|
176
|
+
expect(wrapper.style.top).toBe(`${band.y}px`)
|
|
177
|
+
expect(wrapper.style.height).toBe(`${band.height}px`)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('TrackGutter — skip', () => {
|
|
183
|
+
it('shows no skip control when the host has not wired the edit channel', () => {
|
|
184
|
+
// A button that silently does nothing is worse than no button.
|
|
185
|
+
render(<TrackGutter project={project()} />)
|
|
186
|
+
expect(screen.queryByLabelText(/^Skip /)).toBeNull()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('offers a skip toggle per visual track when wired', () => {
|
|
190
|
+
render(<TrackGutter project={project()} onToggleTrackEnabled={vi.fn()} />)
|
|
191
|
+
expect(screen.getByLabelText('Skip video track')).toBeTruthy()
|
|
192
|
+
expect(screen.getByLabelText('Skip overlay track')).toBeTruthy()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('reports the track index and the NEW enabled value', () => {
|
|
196
|
+
const onToggle = vi.fn()
|
|
197
|
+
render(<TrackGutter project={project()} onToggleTrackEnabled={onToggle} />)
|
|
198
|
+
fireEvent.click(screen.getByLabelText('Skip video track'))
|
|
199
|
+
expect(onToggle).toHaveBeenCalledWith(0, false)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('re-enables a skipped track', () => {
|
|
203
|
+
const onToggle = vi.fn()
|
|
204
|
+
const p = { id: 'p', tracks: [{ id: 't0', items: [video('c0')], enabled: false }] } as unknown as Project
|
|
205
|
+
render(<TrackGutter project={p} showCaptionRow={false} onToggleTrackEnabled={onToggle} />)
|
|
206
|
+
fireEvent.click(screen.getByLabelText('Skip video track'))
|
|
207
|
+
expect(onToggle).toHaveBeenCalledWith(0, true)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('marks a skipped track pressed, so the state is readable without colour', () => {
|
|
211
|
+
const p = { id: 'p', tracks: [{ id: 't0', items: [video('c0')], enabled: false }] } as unknown as Project
|
|
212
|
+
render(<TrackGutter project={p} showCaptionRow={false} onToggleTrackEnabled={vi.fn()} />)
|
|
213
|
+
expect(screen.getByLabelText('Skip video track').getAttribute('aria-pressed')).toBe('true')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('treats a legacy-shaped project as all-enabled', () => {
|
|
217
|
+
const legacy = { id: 'p', tracks: [[video('c0')]] } as unknown as Project
|
|
218
|
+
render(<TrackGutter project={legacy} showCaptionRow={false} onToggleTrackEnabled={vi.fn()} />)
|
|
219
|
+
expect(screen.getByLabelText('Skip video track').getAttribute('aria-pressed')).toBe('false')
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('TrackGutter — rail controls and settings', () => {
|
|
224
|
+
const wired = {
|
|
225
|
+
onToggleTrackEnabled: vi.fn(),
|
|
226
|
+
onSetTrackVolume: vi.fn(),
|
|
227
|
+
onSetTrackMuted: vi.fn(),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
it('puts mute and skip in the RAIL, and volume behind the gear', () => {
|
|
231
|
+
// Mute and skip are flipped constantly while cutting and need to be seen at
|
|
232
|
+
// a glance, so they are inline; the gear is for what you set once.
|
|
233
|
+
render(<TrackGutter project={project()} {...wired} />)
|
|
234
|
+
expect(screen.getByRole('button', { name: 'Mute video track' })).toBeTruthy()
|
|
235
|
+
expect(screen.getByRole('button', { name: 'Skip video track' })).toBeTruthy()
|
|
236
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
237
|
+
|
|
238
|
+
fireEvent.click(screen.getByRole('button', { name: 'Video track settings' }))
|
|
239
|
+
expect(screen.getByLabelText('Video volume')).toBeTruthy()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('offers NO audio controls on an overlay-only track', () => {
|
|
243
|
+
// Overlay and image items carry no audio — `VisualItem.volume`/`muted` are
|
|
244
|
+
// video-only — so a mute button or volume slider there could do nothing.
|
|
245
|
+
render(<TrackGutter project={project()} {...wired} />)
|
|
246
|
+
expect(screen.queryByRole('button', { name: 'Mute overlay track' })).toBeNull()
|
|
247
|
+
expect(screen.queryByRole('button', { name: 'Overlay track settings' })).toBeNull()
|
|
248
|
+
// Skip still applies — you can hide an overlay track.
|
|
249
|
+
expect(screen.getByRole('button', { name: 'Skip overlay track' })).toBeTruthy()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('gives a MIXED track audio controls, reading the items not the track kind', () => {
|
|
253
|
+
const mixed = {
|
|
254
|
+
id: 'p',
|
|
255
|
+
tracks: [{ id: 't0', items: [overlay('o0'), video('c0')] }],
|
|
256
|
+
} as unknown as Project
|
|
257
|
+
render(<TrackGutter project={mixed} showCaptionRow={false} {...wired} />)
|
|
258
|
+
expect(screen.getByRole('button', { name: 'Mute overlay track' })).toBeTruthy()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('the type icon is a plain label, never the settings trigger', () => {
|
|
262
|
+
// It used to double as the trigger, which made it impossible to tell what
|
|
263
|
+
// was clickable.
|
|
264
|
+
render(<TrackGutter project={project()} {...wired} />)
|
|
265
|
+
expect(screen.getByLabelText('Video').tagName).toBe('SPAN')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('renders no gear when volume is not wired', () => {
|
|
269
|
+
render(<TrackGutter project={project()} onToggleTrackEnabled={vi.fn()} />)
|
|
270
|
+
expect(screen.queryByRole('button', { name: 'Video track settings' })).toBeNull()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('toggles mute from the rail with the new value', () => {
|
|
274
|
+
const onSetTrackMuted = vi.fn()
|
|
275
|
+
render(<TrackGutter project={project()} {...wired} onSetTrackMuted={onSetTrackMuted} />)
|
|
276
|
+
fireEvent.click(screen.getByRole('button', { name: 'Mute video track' }))
|
|
277
|
+
expect(onSetTrackMuted).toHaveBeenCalledWith(0, true)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('shows a muted track as pressed, so the state reads without colour', () => {
|
|
281
|
+
const p = { id: 'p', tracks: [{ id: 't0', items: [video('c0')], muted: true }] } as unknown as Project
|
|
282
|
+
render(<TrackGutter project={p} showCaptionRow={false} {...wired} />)
|
|
283
|
+
expect(screen.getByRole('button', { name: 'Mute video track' }).getAttribute('aria-pressed')).toBe('true')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('gives an audio lane mute inline and volume behind the gear, but no skip', () => {
|
|
287
|
+
render(<TrackGutter project={project()} onSetLaneVolume={vi.fn()} onSetLaneMuted={vi.fn()} />)
|
|
288
|
+
expect(screen.getByRole('button', { name: 'Mute audio lane' })).toBeTruthy()
|
|
289
|
+
expect(screen.queryByRole('button', { name: /^Skip audio/ })).toBeNull()
|
|
290
|
+
|
|
291
|
+
fireEvent.click(screen.getByRole('button', { name: 'Audio lane settings' }))
|
|
292
|
+
expect(screen.getByLabelText('Audio lane volume')).toBeTruthy()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('fans a lane mute out over every track id in the lane', () => {
|
|
296
|
+
const onSetLaneMuted = vi.fn()
|
|
297
|
+
const twoTrackLane = {
|
|
298
|
+
id: 'p',
|
|
299
|
+
tracks: [{ id: 't0', items: [video('c0')] }],
|
|
300
|
+
audio: { tracks: [
|
|
301
|
+
{ id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0 },
|
|
302
|
+
{ id: 'a1', src: 'm.mp3', start: 0, end: 2, lane: 0 },
|
|
303
|
+
] },
|
|
304
|
+
} as unknown as Project
|
|
305
|
+
render(<TrackGutter project={twoTrackLane} showCaptionRow={false} onSetLaneMuted={onSetLaneMuted} />)
|
|
306
|
+
fireEvent.click(screen.getByRole('button', { name: 'Mute audio lane' }))
|
|
307
|
+
expect(onSetLaneMuted).toHaveBeenCalledWith(['a0', 'a1'], true)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('shows no magnet control when the host has not wired onSetLaneMagnet', () => {
|
|
311
|
+
render(<TrackGutter project={project()} onSetLaneMuted={vi.fn()} />)
|
|
312
|
+
expect(screen.queryByRole('button', { name: /^Magnetic /i })).toBeNull()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('offers a magnet toggle per audio lane when wired, reflecting the OFF default', () => {
|
|
316
|
+
render(<TrackGutter project={project()} onSetLaneMagnet={vi.fn()} />)
|
|
317
|
+
const toggle = screen.getByRole('button', { name: 'Magnetic audio lane' })
|
|
318
|
+
expect(toggle).toBeTruthy()
|
|
319
|
+
expect(toggle.getAttribute('aria-pressed')).toBe('false')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('reflects an ON magnetic lane', () => {
|
|
323
|
+
const p = {
|
|
324
|
+
id: 'p',
|
|
325
|
+
tracks: [{ id: 't0', items: [video('c0')] }],
|
|
326
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0, magnetic: true }] },
|
|
327
|
+
} as unknown as Project
|
|
328
|
+
render(<TrackGutter project={p} showCaptionRow={false} onSetLaneMagnet={vi.fn()} />)
|
|
329
|
+
expect(screen.getByRole('button', { name: 'Magnetic audio lane' }).getAttribute('aria-pressed')).toBe('true')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('fans a lane magnet toggle out over every track id in the lane, with the NEW value', () => {
|
|
333
|
+
const onSetLaneMagnet = vi.fn()
|
|
334
|
+
const twoTrackLane = {
|
|
335
|
+
id: 'p',
|
|
336
|
+
tracks: [{ id: 't0', items: [video('c0')] }],
|
|
337
|
+
audio: { tracks: [
|
|
338
|
+
{ id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0 },
|
|
339
|
+
{ id: 'a1', src: 'm.mp3', start: 0, end: 2, lane: 0 },
|
|
340
|
+
] },
|
|
341
|
+
} as unknown as Project
|
|
342
|
+
render(<TrackGutter project={twoTrackLane} showCaptionRow={false} onSetLaneMagnet={onSetLaneMagnet} />)
|
|
343
|
+
fireEvent.click(screen.getByRole('button', { name: 'Magnetic audio lane' }))
|
|
344
|
+
expect(onSetLaneMagnet).toHaveBeenCalledWith(['a0', 'a1'], true)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('reads a lane as magnetic only when EVERY track in it is', () => {
|
|
348
|
+
const mixedLane = {
|
|
349
|
+
id: 'p',
|
|
350
|
+
tracks: [{ id: 't0', items: [video('c0')] }],
|
|
351
|
+
audio: { tracks: [
|
|
352
|
+
{ id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0, magnetic: true },
|
|
353
|
+
{ id: 'a1', src: 'm.mp3', start: 0, end: 2, lane: 0 },
|
|
354
|
+
] },
|
|
355
|
+
} as unknown as Project
|
|
356
|
+
render(<TrackGutter project={mixedLane} showCaptionRow={false} onSetLaneMagnet={vi.fn()} />)
|
|
357
|
+
expect(screen.getByRole('button', { name: 'Magnetic audio lane' }).getAttribute('aria-pressed')).toBe('false')
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('previews a volume drag and commits once on release', () => {
|
|
361
|
+
const onSetTrackVolume = vi.fn()
|
|
362
|
+
render(<TrackGutter project={project()} {...wired} onSetTrackVolume={onSetTrackVolume} />)
|
|
363
|
+
fireEvent.click(screen.getByRole('button', { name: 'Video track settings' }))
|
|
364
|
+
const slider = screen.getByLabelText('Video volume')
|
|
365
|
+
|
|
366
|
+
fireEvent.change(slider, { target: { value: '0.5' } })
|
|
367
|
+
expect(onSetTrackVolume).toHaveBeenLastCalledWith(0, 0.5, false)
|
|
368
|
+
|
|
369
|
+
fireEvent.pointerUp(slider)
|
|
370
|
+
expect(onSetTrackVolume).toHaveBeenLastCalledWith(0, 0.5, true)
|
|
371
|
+
})
|
|
372
|
+
})
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* Coverage for the canvas test helpers themselves (`_canvasSelect.ts`).
|
|
4
|
+
*
|
|
5
|
+
* These helpers are about to become the way every migrated DOM-era test
|
|
6
|
+
* addresses a clip, so a silent mis-aim in here would show up downstream as
|
|
7
|
+
* "the assertion is wrong" rather than "the click missed". Each thing the
|
|
8
|
+
* helpers promise is therefore checked against the REAL mounted timeline, not
|
|
9
|
+
* against a restatement of the same arithmetic:
|
|
10
|
+
*
|
|
11
|
+
* - the pinned surface rect is what the mounted surface actually reports;
|
|
12
|
+
* - the fit viewport the helpers compute is the one the surface settled on
|
|
13
|
+
* (proved by seeking: a press at `timeToClientX(t)` lands the clock on `t`);
|
|
14
|
+
* - a press selects the item the caller named, by kind and by id;
|
|
15
|
+
* - `metaKey` extends rather than replaces;
|
|
16
|
+
* - a drag is a drag (many live edits, ONE commit) and a click is a click.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
19
|
+
import { render, cleanup, fireEvent, act } from '@testing-library/react'
|
|
20
|
+
import type { Project } from '../../../types'
|
|
21
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
22
|
+
import Timeline from '../Timeline'
|
|
23
|
+
import { computeDerivedTiming, trackItems } from '../timeline-model'
|
|
24
|
+
import { PLAYHEAD_GRAB_PX } from '../canvas/hit-test'
|
|
25
|
+
import { fitPxPerSecond } from '../canvas/viewport'
|
|
26
|
+
import {
|
|
27
|
+
SURFACE_LEFT,
|
|
28
|
+
SURFACE_WIDTH,
|
|
29
|
+
canvasItemPoint,
|
|
30
|
+
canvasSurface,
|
|
31
|
+
canvasViewport,
|
|
32
|
+
dragCanvasItem,
|
|
33
|
+
focusCanvasRoot,
|
|
34
|
+
installCanvasHarness,
|
|
35
|
+
resolveCanvasTarget,
|
|
36
|
+
selectCanvasItem,
|
|
37
|
+
timeToClientX,
|
|
38
|
+
} from './_canvasSelect'
|
|
39
|
+
|
|
40
|
+
let uninstall: () => void
|
|
41
|
+
|
|
42
|
+
beforeEach(() => { uninstall = installCanvasHarness() })
|
|
43
|
+
afterEach(() => { cleanup(); uninstall() })
|
|
44
|
+
|
|
45
|
+
/** The same two-track shape `VideoEditor.keymap.test.tsx` uses, so what is
|
|
46
|
+
* proved here is what the migrated tests rely on. Content runs 0–4s, so
|
|
47
|
+
* `totalDuration` is 4 + max(5, 0.8) = 9s — the number the fit is against. */
|
|
48
|
+
function makeProject(): Project {
|
|
49
|
+
return {
|
|
50
|
+
id: 'p1',
|
|
51
|
+
status: 'draft',
|
|
52
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
53
|
+
tracks: [
|
|
54
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
55
|
+
[{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Hello' } }],
|
|
56
|
+
],
|
|
57
|
+
} as unknown as Project
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mount(project: Project, selectedIds: string[] = [], clockAt = 0) {
|
|
61
|
+
const clock = createPlaybackClock(clockAt)
|
|
62
|
+
const onSelectIds = vi.fn()
|
|
63
|
+
const onProjectChange = vi.fn()
|
|
64
|
+
const onOverlayEdit = vi.fn()
|
|
65
|
+
const utils = render(
|
|
66
|
+
<Timeline
|
|
67
|
+
project={project}
|
|
68
|
+
clock={clock}
|
|
69
|
+
selectedIds={selectedIds}
|
|
70
|
+
onSelectIds={onSelectIds}
|
|
71
|
+
onProjectChange={onProjectChange}
|
|
72
|
+
onOverlayEdit={onOverlayEdit}
|
|
73
|
+
/>,
|
|
74
|
+
)
|
|
75
|
+
return { clock, onSelectIds, onProjectChange, onOverlayEdit, ...utils }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A bare press/release at a page point, bypassing the selector plumbing —
|
|
79
|
+
* used only to prove the coordinate math against the surface's own viewport. */
|
|
80
|
+
function pressAndRelease(surface: HTMLElement, clientX: number, clientY: number) {
|
|
81
|
+
fireEvent.mouseDown(surface, { clientX, clientY, button: 0, bubbles: true })
|
|
82
|
+
act(() => { document.dispatchEvent(new MouseEvent('mouseup', { clientX, clientY, bubbles: true })) })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('_canvasSelect — harness', () => {
|
|
86
|
+
it('pins the canvas surface to a real rect and leaves everything else spanning the column', () => {
|
|
87
|
+
const { container } = mount(makeProject())
|
|
88
|
+
const surface = canvasSurface(container)
|
|
89
|
+
|
|
90
|
+
expect(surface.getBoundingClientRect().left).toBe(SURFACE_LEFT)
|
|
91
|
+
expect(surface.getBoundingClientRect().width).toBe(SURFACE_WIDTH)
|
|
92
|
+
// Anything that isn't the surface starts at the left of the column.
|
|
93
|
+
expect((container.firstElementChild as HTMLElement).getBoundingClientRect().left).toBe(0)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('restores the real getContext / getBoundingClientRect on teardown', () => {
|
|
97
|
+
const beforeCtx = HTMLCanvasElement.prototype.getContext
|
|
98
|
+
const beforeRect = Element.prototype.getBoundingClientRect
|
|
99
|
+
|
|
100
|
+
const teardown = installCanvasHarness()
|
|
101
|
+
expect(HTMLCanvasElement.prototype.getContext).not.toBe(beforeCtx)
|
|
102
|
+
expect(Element.prototype.getBoundingClientRect).not.toBe(beforeRect)
|
|
103
|
+
|
|
104
|
+
teardown()
|
|
105
|
+
expect(HTMLCanvasElement.prototype.getContext).toBe(beforeCtx)
|
|
106
|
+
expect(Element.prototype.getBoundingClientRect).toBe(beforeRect)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// The DOM timeline is retired, so a mounted `<Timeline>` ALWAYS has a canvas
|
|
110
|
+
// surface — the only way to miss it now is aiming the helper at a container
|
|
111
|
+
// that holds no timeline, which is what this guard is left to catch.
|
|
112
|
+
it('canvasSurface says what is wrong when the container holds no timeline', () => {
|
|
113
|
+
const { container } = render(<div />)
|
|
114
|
+
expect(() => canvasSurface(container)).toThrow(/data-timeline-canvas/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('focusCanvasRoot focuses the tabIndex=0 root the Delete/Enter bindings are scoped to', () => {
|
|
118
|
+
const { container } = mount(makeProject())
|
|
119
|
+
focusCanvasRoot(container)
|
|
120
|
+
expect(document.activeElement).toBe(container.querySelector('[tabindex="0"]'))
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('_canvasSelect — coordinates', () => {
|
|
125
|
+
it('fits the whole timeline INCLUDING drag headroom, not just the content', () => {
|
|
126
|
+
const project = makeProject()
|
|
127
|
+
const { contentDuration, totalDuration } = computeDerivedTiming(project)
|
|
128
|
+
|
|
129
|
+
expect(contentDuration).toBe(4)
|
|
130
|
+
expect(totalDuration).toBe(9)
|
|
131
|
+
// The scale is 1000px / 9s, NOT 1000px / 4s — the fit is against the
|
|
132
|
+
// padded duration. Anyone hardcoding a round px/second is wrong.
|
|
133
|
+
expect(canvasViewport(project).pxPerSecond).toBe(fitPxPerSecond(SURFACE_WIDTH, totalDuration))
|
|
134
|
+
expect(canvasViewport(project).pxPerSecond).toBeCloseTo(1000 / 9, 10)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('maps t=0 to the surface origin and the full duration to its right edge', () => {
|
|
138
|
+
const project = makeProject()
|
|
139
|
+
const { totalDuration } = computeDerivedTiming(project)
|
|
140
|
+
expect(timeToClientX(project, 0)).toBe(SURFACE_LEFT)
|
|
141
|
+
expect(timeToClientX(project, totalDuration)).toBeCloseTo(SURFACE_LEFT + SURFACE_WIDTH, 10)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('agrees with the viewport the MOUNTED surface actually settled on', () => {
|
|
145
|
+
// The real proof: press on empty track area at the x the helper computes
|
|
146
|
+
// for t=6 and the surface's own `xToTime` must land the playhead there. If
|
|
147
|
+
// the helper's fit differed from the mounted one by so much as a percent,
|
|
148
|
+
// this seek would miss.
|
|
149
|
+
const project = makeProject()
|
|
150
|
+
const { container, clock } = mount(project)
|
|
151
|
+
const surface = canvasSurface(container)
|
|
152
|
+
const videoRow = resolveCanvasTarget(project, { type: 'video' })
|
|
153
|
+
|
|
154
|
+
pressAndRelease(surface, timeToClientX(project, 6), videoRow.y + videoRow.height / 2)
|
|
155
|
+
expect(clock.get()).toBeCloseTo(6, 6)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('resolves a target by kind and by id, and names the ids it knows when it cannot', () => {
|
|
159
|
+
const project = makeProject()
|
|
160
|
+
expect(resolveCanvasTarget(project, { type: 'video' }).id).toBe('clip-0')
|
|
161
|
+
expect(resolveCanvasTarget(project, { type: 'overlay' }).id).toBe('overlay-1')
|
|
162
|
+
expect(resolveCanvasTarget(project, { id: 'overlay-1' }).start).toBe(0)
|
|
163
|
+
expect(() => resolveCanvasTarget(project, { id: 'nope' })).toThrow(/clip-0/)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('dodges the playhead grab band so a press on a clip selects instead of scrubbing', () => {
|
|
167
|
+
const project = makeProject()
|
|
168
|
+
const centred = canvasItemPoint(project, { type: 'video' })
|
|
169
|
+
// Park the playhead exactly on the clip's middle — where the press would
|
|
170
|
+
// otherwise land, and where `grabsPlayhead` would turn it into a scrub.
|
|
171
|
+
const dodged = canvasItemPoint(project, { type: 'video' }, { playheadTime: 2 })
|
|
172
|
+
expect(centred.clientX).toBeCloseTo(timeToClientX(project, 2), 6)
|
|
173
|
+
expect(dodged.clientX - centred.clientX).toBeCloseTo(PLAYHEAD_GRAB_PX + 1, 6)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('honours an explicit `at` time instead of the item middle', () => {
|
|
177
|
+
const project = makeProject()
|
|
178
|
+
const point = canvasItemPoint(project, { type: 'video' }, { at: 1 })
|
|
179
|
+
expect(point.clientX).toBeCloseTo(timeToClientX(project, 1), 6)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('_canvasSelect — selectCanvasItem', () => {
|
|
184
|
+
it('selects the first item of a kind — the canvas equivalent of clicking "▪ video"', () => {
|
|
185
|
+
const project = makeProject()
|
|
186
|
+
const { container, onSelectIds } = mount(project)
|
|
187
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
188
|
+
expect(onSelectIds).toHaveBeenCalledWith(['clip-0'])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('selects by id', () => {
|
|
192
|
+
const project = makeProject()
|
|
193
|
+
const { container, onSelectIds } = mount(project)
|
|
194
|
+
selectCanvasItem(container, project, { id: 'overlay-1' })
|
|
195
|
+
expect(onSelectIds).toHaveBeenCalledWith(['overlay-1'])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('replaces the selection on a plain click', () => {
|
|
199
|
+
const project = makeProject()
|
|
200
|
+
const { container, onSelectIds } = mount(project, ['overlay-1'])
|
|
201
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
202
|
+
expect(onSelectIds).toHaveBeenCalledWith(['clip-0'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('EXTENDS the selection with metaKey — the modifier the DOM helper used', () => {
|
|
206
|
+
const project = makeProject()
|
|
207
|
+
const { container, onSelectIds } = mount(project, ['clip-0'])
|
|
208
|
+
selectCanvasItem(container, project, { type: 'overlay' }, { metaKey: true })
|
|
209
|
+
expect(onSelectIds).toHaveBeenCalledWith(['clip-0', 'overlay-1'])
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('still selects when the playhead is parked on the clip it is aiming at', () => {
|
|
213
|
+
const project = makeProject()
|
|
214
|
+
const { container, onSelectIds } = mount(project, [], 2)
|
|
215
|
+
selectCanvasItem(container, project, { type: 'video' }, { playheadTime: 2 })
|
|
216
|
+
expect(onSelectIds).toHaveBeenCalledWith(['clip-0'])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('is a CLICK, not a drag: it never commits an edit', () => {
|
|
220
|
+
const project = makeProject()
|
|
221
|
+
const { container, onProjectChange, onOverlayEdit } = mount(project)
|
|
222
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
223
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
224
|
+
expect(onOverlayEdit).not.toHaveBeenCalled()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('_canvasSelect — dragCanvasItem', () => {
|
|
229
|
+
it('is a DRAG: many live edits, exactly ONE commit', () => {
|
|
230
|
+
const project = makeProject()
|
|
231
|
+
const { container, onProjectChange, onOverlayEdit } = mount(project)
|
|
232
|
+
|
|
233
|
+
dragCanvasItem(container, project, { type: 'video' }, { dxPx: 200, steps: 5 })
|
|
234
|
+
|
|
235
|
+
// One live edit per intermediate move — the property the "a whole drag
|
|
236
|
+
// undoes in one step" regression test depends on.
|
|
237
|
+
expect(onProjectChange.mock.calls.length).toBeGreaterThan(1)
|
|
238
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
239
|
+
|
|
240
|
+
const committed = onOverlayEdit.mock.calls[0][0] as Project
|
|
241
|
+
const moved = trackItems(committed).flat().find((i) => i.id === 'clip-0')
|
|
242
|
+
expect(moved?.start).toBeGreaterThan(0)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('lands the item near the requested time', () => {
|
|
246
|
+
const project = makeProject()
|
|
247
|
+
const { container, onOverlayEdit } = mount(project)
|
|
248
|
+
|
|
249
|
+
// The clip's body point sits at its middle (t=2); asking to release two
|
|
250
|
+
// seconds later moves the whole clip two seconds later.
|
|
251
|
+
dragCanvasItem(container, project, { type: 'video' }, { toTime: 4 })
|
|
252
|
+
|
|
253
|
+
const committed = onOverlayEdit.mock.calls[0][0] as Project
|
|
254
|
+
const moved = trackItems(committed).flat().find((i) => i.id === 'clip-0')
|
|
255
|
+
expect(moved?.start).toBeCloseTo(2, 1)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('refuses travel that would never cross the drag threshold', () => {
|
|
259
|
+
const project = makeProject()
|
|
260
|
+
const { container, onOverlayEdit } = mount(project)
|
|
261
|
+
expect(() => dragCanvasItem(container, project, { type: 'video' }, { dxPx: 2 }))
|
|
262
|
+
.toThrow(/DRAG_THRESHOLD_PX/)
|
|
263
|
+
// Nothing was dispatched — it bailed before the press.
|
|
264
|
+
expect(onOverlayEdit).not.toHaveBeenCalled()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('needs a destination', () => {
|
|
268
|
+
const project = makeProject()
|
|
269
|
+
const { container } = mount(project)
|
|
270
|
+
expect(() => dragCanvasItem(container, project, { type: 'video' }, {}))
|
|
271
|
+
.toThrow(/toTime.*dxPx/)
|
|
272
|
+
})
|
|
273
|
+
})
|