@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,253 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The keyframe-strip popup (SP9b T3.3) — `Timeline.fadeCurveMenu.test.tsx`'s
|
|
4
|
+
* exact sibling. A right-click on a keyframe-strip diamond (selected,
|
|
5
|
+
* keyframed overlay only — see hit-test.ts's `'keyframe'` kind) opens a small
|
|
6
|
+
* menu of the six `EASING_NAMES` plus a "Remove keyframe" action; picking an
|
|
7
|
+
* easing or removing applies to EVERY prop the diamond represents (the
|
|
8
|
+
* "union of times" the strip draws — see `keyframe-strip.ts`), as ONE undo
|
|
9
|
+
* entry.
|
|
10
|
+
*
|
|
11
|
+
* `TimelineCanvas`'s `contextmenu` handler does its own hit-test and calls
|
|
12
|
+
* `onKeyframeMenu` with CLIENT coordinates; `Timeline` owns the menu's
|
|
13
|
+
* open/closed state and renders it as a `position: fixed` DOM overlay —
|
|
14
|
+
* mirrors `Timeline.fadeCurveMenu.test.tsx`'s own stubbing (`installCanvasHarness`,
|
|
15
|
+
* see `_canvasSelect.ts`).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
18
|
+
import { render, cleanup, fireEvent, screen } from '@testing-library/react'
|
|
19
|
+
import type { Project } from '../../../types'
|
|
20
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
21
|
+
import Timeline from '../Timeline'
|
|
22
|
+
import { computeTimelineLayout } from '../canvas/draw'
|
|
23
|
+
import { installCanvasHarness, SURFACE_LEFT, timeToClientX } from './_canvasSelect'
|
|
24
|
+
|
|
25
|
+
let uninstall: () => void
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
uninstall = installCanvasHarness()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
cleanup()
|
|
33
|
+
uninstall()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** o0 (2s-4s, overlay) keyframed on offsetX at t=0.5 AND t=1.5, and on
|
|
37
|
+
* opacity at t=0.5 only. Union of times is {0.5, 1.5} (`keyframe-strip.ts`'s
|
|
38
|
+
* merged strip): the diamond at t=0.5 represents BOTH props (used to prove
|
|
39
|
+
* an action fans out to every prop sharing an instant), the diamond at
|
|
40
|
+
* t=1.5 represents offsetX alone AND is the item's LAST keyframe (used for
|
|
41
|
+
* the disabled-easing-picker test). */
|
|
42
|
+
function makeProject(): Project {
|
|
43
|
+
return {
|
|
44
|
+
id: 'p1',
|
|
45
|
+
status: 'draft',
|
|
46
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
47
|
+
tracks: [{
|
|
48
|
+
id: 'trk-0',
|
|
49
|
+
items: [{
|
|
50
|
+
id: 'o0', type: 'overlay', start: 2, end: 4,
|
|
51
|
+
keyframes: [
|
|
52
|
+
{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }, { t: 1.5, value: 10 }] },
|
|
53
|
+
{ prop: 'opacity', points: [{ t: 0.5, value: 1 }] },
|
|
54
|
+
],
|
|
55
|
+
}],
|
|
56
|
+
}],
|
|
57
|
+
} as unknown as Project
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mount(project: Project = makeProject()) {
|
|
61
|
+
const clock = createPlaybackClock()
|
|
62
|
+
const onProjectChange = vi.fn()
|
|
63
|
+
const onOverlayEdit = vi.fn()
|
|
64
|
+
const utils = render(
|
|
65
|
+
<Timeline
|
|
66
|
+
project={project}
|
|
67
|
+
clock={clock}
|
|
68
|
+
onProjectChange={onProjectChange}
|
|
69
|
+
onOverlayEdit={onOverlayEdit}
|
|
70
|
+
selectedIds={['o0']}
|
|
71
|
+
/>,
|
|
72
|
+
)
|
|
73
|
+
const surface = utils.container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
74
|
+
return { clock, onProjectChange, onOverlayEdit, surface, project, ...utils }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** CLIENT (x, y) for the diamond at item-relative `t` on `project`'s o0,
|
|
78
|
+
* computed the same way `hit-test.ts`'s keyframe zone does: `item.start + t`
|
|
79
|
+
* through `timeToClientX` — the SAME fit-to-view viewport the mounted
|
|
80
|
+
* surface settles to on render — at a y a couple px above the row's own
|
|
81
|
+
* bottom edge (inside the bottom strip zone the diamond hit-tests in). */
|
|
82
|
+
function keyframeClientPoint(project: Project, t: number) {
|
|
83
|
+
const row = computeTimelineLayout(project).rows[0]
|
|
84
|
+
const clientX = timeToClientX(project, 2 + t) // o0.start is 2
|
|
85
|
+
return { clientX, clientY: row.y + row.height - 2 }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Easing buttons render VISIBLE text (unlike the icon-only fade picker), so
|
|
89
|
+
* their accessible name is that text — same `getByRole` lookup either way. */
|
|
90
|
+
function easingButton(name: string) {
|
|
91
|
+
return screen.getByRole('button', { name })
|
|
92
|
+
}
|
|
93
|
+
function queryEasingButton(name: string) {
|
|
94
|
+
return screen.queryByRole('button', { name })
|
|
95
|
+
}
|
|
96
|
+
function removeButton() {
|
|
97
|
+
return screen.getByRole('button', { name: 'Remove keyframe' })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('Timeline — keyframe-strip popup (right-click a keyframe diamond)', () => {
|
|
101
|
+
it('opens the picker with all six easing options and a remove action on a diamond right-click', () => {
|
|
102
|
+
const { surface, project } = mount()
|
|
103
|
+
fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
|
|
104
|
+
for (const name of ['Linear', 'Ease', 'Ease In', 'Ease Out', 'Ease In Out', 'Hold']) {
|
|
105
|
+
expect(easingButton(name)).toBeInTheDocument()
|
|
106
|
+
}
|
|
107
|
+
expect(removeButton()).toBeInTheDocument()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does NOT open the picker from a right-click elsewhere on the clip', () => {
|
|
111
|
+
const { surface, project } = mount()
|
|
112
|
+
// Same x as a diamond, but well above the strip's bottom zone.
|
|
113
|
+
const row = computeTimelineLayout(project).rows[0]
|
|
114
|
+
fireEvent.contextMenu(surface, { clientX: SURFACE_LEFT + 300, clientY: row.y + 2 })
|
|
115
|
+
expect(queryEasingButton('Linear')).not.toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('picking an easing applies it to every prop sharing the diamond’s t, as ONE undo entry, and closes the menu', () => {
|
|
119
|
+
const { surface, onProjectChange, onOverlayEdit, project } = mount()
|
|
120
|
+
fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
|
|
121
|
+
|
|
122
|
+
fireEvent.click(easingButton('Ease In'))
|
|
123
|
+
|
|
124
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
125
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
126
|
+
const committed = onOverlayEdit.mock.calls[0][0] as Project
|
|
127
|
+
const item = committed.tracks![0].items[0]
|
|
128
|
+
const offsetX = item.keyframes!.find(t => t.prop === 'offsetX')!
|
|
129
|
+
const opacity = item.keyframes!.find(t => t.prop === 'opacity')!
|
|
130
|
+
expect(offsetX.points.find(p => p.t === 0.5)?.easing).toBe('ease-in')
|
|
131
|
+
expect(opacity.points.find(p => p.t === 0.5)?.easing).toBe('ease-in')
|
|
132
|
+
// The OTHER keyframe on offsetX (t=1.5) is untouched.
|
|
133
|
+
expect(offsetX.points.find(p => p.t === 1.5)?.easing).toBeUndefined()
|
|
134
|
+
expect(queryEasingButton('Linear')).not.toBeInTheDocument()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('removing clears every prop at that t, dropping a track entirely once it has no points left', () => {
|
|
138
|
+
const { surface, onProjectChange, onOverlayEdit, project } = mount()
|
|
139
|
+
fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
|
|
140
|
+
|
|
141
|
+
fireEvent.click(removeButton())
|
|
142
|
+
|
|
143
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
144
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
145
|
+
const committed = onOverlayEdit.mock.calls[0][0] as Project
|
|
146
|
+
const item = committed.tracks![0].items[0]
|
|
147
|
+
// offsetX keeps its OTHER point (t=1.5); its t=0.5 point is gone.
|
|
148
|
+
const offsetX = item.keyframes!.find(t => t.prop === 'offsetX')!
|
|
149
|
+
expect(offsetX.points.map(p => p.t)).toEqual([1.5])
|
|
150
|
+
// opacity had ONLY the t=0.5 point — the whole track is gone, not just
|
|
151
|
+
// emptied (see keyframeOps.ts's `removeKeyframe`/`withTrack`).
|
|
152
|
+
expect(item.keyframes!.find(t => t.prop === 'opacity')).toBeUndefined()
|
|
153
|
+
expect(queryEasingButton('Linear')).not.toBeInTheDocument()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('disables the easing picker on the LAST diamond, but removal still works there', () => {
|
|
157
|
+
const { surface, onProjectChange, onOverlayEdit, project } = mount()
|
|
158
|
+
// t=1.5 is offsetX's last (and only remaining) keyframe once t=0.5 is
|
|
159
|
+
// accounted for by the fixture — the item's overall last keyframe too.
|
|
160
|
+
fireEvent.contextMenu(surface, keyframeClientPoint(project, 1.5))
|
|
161
|
+
|
|
162
|
+
expect(easingButton('Linear')).toBeDisabled()
|
|
163
|
+
expect(easingButton('Hold')).toBeDisabled()
|
|
164
|
+
fireEvent.click(easingButton('Linear'))
|
|
165
|
+
// Disabled buttons don't fire clicks — nothing committed.
|
|
166
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
167
|
+
|
|
168
|
+
fireEvent.click(removeButton())
|
|
169
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
170
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
171
|
+
const committed = onOverlayEdit.mock.calls[0][0] as Project
|
|
172
|
+
const item = committed.tracks![0].items[0]
|
|
173
|
+
const offsetX = item.keyframes!.find(t => t.prop === 'offsetX')!
|
|
174
|
+
expect(offsetX.points.map(p => p.t)).toEqual([0.5])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('highlights the shared easing when every prop at t agrees, and none when they disagree', () => {
|
|
178
|
+
const project = makeProject()
|
|
179
|
+
// Pre-set offsetX and opacity's t=0.5 points to the SAME easing.
|
|
180
|
+
project.tracks![0].items[0].keyframes![0].points[0].easing = 'hold'
|
|
181
|
+
project.tracks![0].items[0].keyframes![1].points[0].easing = 'hold'
|
|
182
|
+
const { surface } = mount(project)
|
|
183
|
+
fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
|
|
184
|
+
expect(easingButton('Hold')).toHaveAttribute('aria-pressed', 'true')
|
|
185
|
+
expect(easingButton('Linear')).toHaveAttribute('aria-pressed', 'false')
|
|
186
|
+
|
|
187
|
+
fireEvent.click(screen.getByTestId('keyframe-menu-backdrop'))
|
|
188
|
+
|
|
189
|
+
// Now disagree: offsetX stays 'hold', opacity becomes 'ease'.
|
|
190
|
+
const disagreeing = makeProject()
|
|
191
|
+
disagreeing.tracks![0].items[0].keyframes![0].points[0].easing = 'hold'
|
|
192
|
+
disagreeing.tracks![0].items[0].keyframes![1].points[0].easing = 'ease'
|
|
193
|
+
const remount = mount(disagreeing)
|
|
194
|
+
fireEvent.contextMenu(remount.surface, keyframeClientPoint(disagreeing, 0.5))
|
|
195
|
+
for (const name of ['Linear', 'Ease', 'Ease In', 'Ease Out', 'Ease In Out', 'Hold']) {
|
|
196
|
+
expect(easingButton(name)).toHaveAttribute('aria-pressed', 'false')
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('Escape closes the picker without committing anything', () => {
|
|
201
|
+
const { surface, onProjectChange, onOverlayEdit, project } = mount()
|
|
202
|
+
fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
|
|
203
|
+
expect(easingButton('Linear')).toBeInTheDocument()
|
|
204
|
+
|
|
205
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
206
|
+
|
|
207
|
+
expect(queryEasingButton('Linear')).not.toBeInTheDocument()
|
|
208
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
209
|
+
expect(onOverlayEdit).not.toHaveBeenCalled()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('clicking outside the menu closes it without touching the project', () => {
|
|
213
|
+
const { surface, onProjectChange, onOverlayEdit, project } = mount()
|
|
214
|
+
fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
|
|
215
|
+
expect(easingButton('Linear')).toBeInTheDocument()
|
|
216
|
+
|
|
217
|
+
fireEvent.click(screen.getByTestId('keyframe-menu-backdrop'))
|
|
218
|
+
|
|
219
|
+
expect(queryEasingButton('Linear')).not.toBeInTheDocument()
|
|
220
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
221
|
+
expect(onOverlayEdit).not.toHaveBeenCalled()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('freezes the value when the removed keyframe was a track\'s LAST point', () => {
|
|
225
|
+
// Plain `removeKeyframe` drops the track WITHOUT writing the sampled value
|
|
226
|
+
// into the static scalar, so the overlay would snap back to the stale
|
|
227
|
+
// `opacity: 0.1`. Both removal paths share `removeKeyframesAt` precisely so
|
|
228
|
+
// this menu and Delete cannot disagree.
|
|
229
|
+
const base = makeProject()
|
|
230
|
+
const stale = {
|
|
231
|
+
...base,
|
|
232
|
+
tracks: [{
|
|
233
|
+
...base.tracks![0],
|
|
234
|
+
items: [{
|
|
235
|
+
...base.tracks![0].items[0],
|
|
236
|
+
opacity: 0.1,
|
|
237
|
+
keyframes: [
|
|
238
|
+
{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }, { t: 1.5, value: 10 }] },
|
|
239
|
+
{ prop: 'opacity', points: [{ t: 0.5, value: 0.4 }] },
|
|
240
|
+
],
|
|
241
|
+
}],
|
|
242
|
+
}],
|
|
243
|
+
} as unknown as Project
|
|
244
|
+
const { surface, onOverlayEdit, project } = mount(stale)
|
|
245
|
+
fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
|
|
246
|
+
|
|
247
|
+
fireEvent.click(removeButton())
|
|
248
|
+
|
|
249
|
+
const item = (onOverlayEdit.mock.calls[0][0] as Project).tracks![0].items[0]
|
|
250
|
+
expect(item.keyframes!.find(t => t.prop === 'opacity')).toBeUndefined()
|
|
251
|
+
expect(item.opacity).toBeCloseTo(0.4, 5) // the curve's value, NOT the stale 0.1
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, cleanup, fireEvent, act } from '@testing-library/react'
|
|
3
|
+
import type { Project } from '../../../types'
|
|
4
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
5
|
+
import Timeline, { type TimelineActions } from '../Timeline'
|
|
6
|
+
import { installCanvasHarness } from './_canvasSelect'
|
|
7
|
+
|
|
8
|
+
// SP5 — the DOM timeline is retired, so every Timeline mount is a canvas one
|
|
9
|
+
// and needs the canvas harness (stubbed getContext/getBoundingClientRect) even
|
|
10
|
+
// though none of these tests click or drag — TimelineCanvas paints on mount.
|
|
11
|
+
let uninstallCanvasHarness: () => void
|
|
12
|
+
beforeEach(() => { uninstallCanvasHarness = installCanvasHarness() })
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup()
|
|
15
|
+
uninstallCanvasHarness()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function makeProject(): Project {
|
|
19
|
+
return {
|
|
20
|
+
id: 'p1',
|
|
21
|
+
status: 'draft',
|
|
22
|
+
settings: { resolution: [1080, 1920], fps: 10 }, // 10fps -> 0.1s frame step, easy to assert
|
|
23
|
+
tracks: [[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }]],
|
|
24
|
+
} as unknown as Project
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeProjectWithCaptions(): Project {
|
|
28
|
+
return {
|
|
29
|
+
...makeProject(),
|
|
30
|
+
captions: {
|
|
31
|
+
style: 'clean',
|
|
32
|
+
segments: [
|
|
33
|
+
{ id: 'cap-0', text: 'hello', start: 0, end: 1 },
|
|
34
|
+
{ id: 'cap-1', text: 'world', start: 1, end: 2 },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
} as unknown as Project
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Delete/Enter are focus-scoped to Timeline's own root (the `tabIndex={0}`
|
|
41
|
+
* container), mirroring pre-SP5 behavior — unlike arrows/Escape, which stay
|
|
42
|
+
* document-level. Timeline's root is the outermost DOM node it renders
|
|
43
|
+
* (`TimelineContext.Provider` contributes no element of its own), so it's
|
|
44
|
+
* always `container.firstElementChild` in these tests. */
|
|
45
|
+
function focusTimelineRoot(container: HTMLElement) {
|
|
46
|
+
(container.firstElementChild as HTMLElement).focus()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('Timeline — T9 keymap (arrows / delete / enter / escape)', () => {
|
|
50
|
+
it('ArrowRight steps the clock forward by one frame on a plain target', () => {
|
|
51
|
+
const clock = createPlaybackClock(0)
|
|
52
|
+
render(<Timeline project={makeProject()} clock={clock} />)
|
|
53
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight' }) })
|
|
54
|
+
expect(clock.get()).toBeCloseTo(0.1, 5)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Shift is TEN FRAMES, not one second. At this fixture's 10fps the two
|
|
58
|
+
// happen to coincide at 1.0s, so both directions are asserted at a second
|
|
59
|
+
// fps below where they diverge — otherwise the old wall-clock behaviour
|
|
60
|
+
// would pass this test unchanged.
|
|
61
|
+
it('Shift+ArrowRight steps ten frames forward, and Shift+ArrowLeft ten back', () => {
|
|
62
|
+
const clock = createPlaybackClock(2)
|
|
63
|
+
render(<Timeline project={makeProject()} clock={clock} />)
|
|
64
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', shiftKey: true }) })
|
|
65
|
+
expect(clock.get()).toBeCloseTo(3, 5)
|
|
66
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft', shiftKey: true }) })
|
|
67
|
+
expect(clock.get()).toBeCloseTo(2, 5)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('the shifted step is ten FRAMES, so it scales with fps rather than being a flat second', () => {
|
|
71
|
+
const clock = createPlaybackClock(2)
|
|
72
|
+
const project = makeProject()
|
|
73
|
+
render(<Timeline project={{ ...project, settings: { ...project.settings!, fps: 25 } }} clock={clock} />)
|
|
74
|
+
// 10 frames at 25fps is 0.4s. The old `shiftKey ? 1` would give 3.0.
|
|
75
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', shiftKey: true }) })
|
|
76
|
+
expect(clock.get()).toBeCloseTo(2.4, 5)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Content end = the furthest item's end across ALL clips/overlays/audio
|
|
80
|
+
// (`contentDuration` from computeDerivedTiming), not the zoom/scroll
|
|
81
|
+
// headroom-padded `totalDuration`. This fixture's video track ends at 4s,
|
|
82
|
+
// but a later audio track pushes the real content end out to 7s — proving
|
|
83
|
+
// the jump lands on the furthest ELEMENT, not just the longest video clip.
|
|
84
|
+
// totalDuration would pad that out further still (7 + max(5, 7*0.2) = 12),
|
|
85
|
+
// so asserting 7 (not 12, not 4) pins down both distinctions at once.
|
|
86
|
+
function makeProjectWithFurtherAudio(): Project {
|
|
87
|
+
const project = makeProject()
|
|
88
|
+
return {
|
|
89
|
+
...project,
|
|
90
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 5, end: 7, lane: 0 }] },
|
|
91
|
+
} as unknown as Project
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
it('Cmd/Ctrl+ArrowLeft jumps the playhead to the start (0)', () => {
|
|
95
|
+
const clock = createPlaybackClock(3)
|
|
96
|
+
render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
|
|
97
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft', metaKey: true }) })
|
|
98
|
+
expect(clock.get()).toBe(0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('Ctrl+ArrowLeft (non-Mac mod) also jumps to the start', () => {
|
|
102
|
+
const clock = createPlaybackClock(3)
|
|
103
|
+
render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
|
|
104
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft', ctrlKey: true }) })
|
|
105
|
+
expect(clock.get()).toBe(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('Cmd/Ctrl+ArrowRight jumps the playhead to the content end (furthest element, not headroom)', () => {
|
|
109
|
+
const clock = createPlaybackClock(0)
|
|
110
|
+
render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
|
|
111
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', metaKey: true }) })
|
|
112
|
+
// 7, not 4 (the video clip alone) and not 12 (totalDuration's headroom).
|
|
113
|
+
expect(clock.get()).toBe(7)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('a plain ArrowLeft/ArrowRight still frame-steps and does NOT jump, even with the further-audio fixture', () => {
|
|
117
|
+
const clock = createPlaybackClock(2)
|
|
118
|
+
render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
|
|
119
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight' }) })
|
|
120
|
+
expect(clock.get()).toBeCloseTo(2.1, 5)
|
|
121
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) })
|
|
122
|
+
expect(clock.get()).toBeCloseTo(2, 5)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('Cmd+ArrowLeft/Right does not ALSO fire the frame-step binding (no double-action)', () => {
|
|
126
|
+
const clock = createPlaybackClock(3)
|
|
127
|
+
render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
|
|
128
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', metaKey: true }) })
|
|
129
|
+
// If frame-step also fired, this would be 7 + 0.1 instead of 7.
|
|
130
|
+
expect(clock.get()).toBe(7)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('Cmd/Ctrl+Arrow jump is inert on an empty project (guard: totalDuration > 0)', () => {
|
|
134
|
+
const clock = createPlaybackClock(0)
|
|
135
|
+
const emptyProject = { ...makeProject(), tracks: [] } as unknown as Project
|
|
136
|
+
render(<Timeline project={emptyProject} clock={clock} />)
|
|
137
|
+
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', metaKey: true }) })
|
|
138
|
+
expect(clock.get()).toBe(0)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('does not step when the target is an input', () => {
|
|
142
|
+
const clock = createPlaybackClock(0)
|
|
143
|
+
const { container } = render(
|
|
144
|
+
<div>
|
|
145
|
+
<input data-testid="somewhere-else" />
|
|
146
|
+
<Timeline project={makeProject()} clock={clock} />
|
|
147
|
+
</div>,
|
|
148
|
+
)
|
|
149
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
150
|
+
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
|
151
|
+
expect(clock.get()).toBe(0)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('does not step when `modalOpen` is true (a host-level dialog is up)', () => {
|
|
155
|
+
const clock = createPlaybackClock(0)
|
|
156
|
+
render(<Timeline project={makeProject()} clock={clock} modalOpen />)
|
|
157
|
+
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
|
158
|
+
expect(clock.get()).toBe(0)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Enter and Escape used to place and clear the A/B range markers. That
|
|
162
|
+
// feature is gone, so the timeline binds neither: pressing them with the
|
|
163
|
+
// timeline focused must not move the playhead or touch the project.
|
|
164
|
+
it('Enter and Escape are not bound', () => {
|
|
165
|
+
const clock = createPlaybackClock(2)
|
|
166
|
+
const onProjectChange = vi.fn()
|
|
167
|
+
const { container } = render(
|
|
168
|
+
<Timeline project={makeProject()} clock={clock} onProjectChange={onProjectChange} />,
|
|
169
|
+
)
|
|
170
|
+
focusTimelineRoot(container)
|
|
171
|
+
fireEvent.keyDown(document.body, { key: 'Enter' })
|
|
172
|
+
fireEvent.keyDown(document.body, { key: 'Escape' })
|
|
173
|
+
expect(clock.get()).toBe(2)
|
|
174
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('Delete/Backspace two-step delete: fires only with a selection, commits via onProjectChange + onOverlayEdit + clears selection', () => {
|
|
178
|
+
const clock = createPlaybackClock(0)
|
|
179
|
+
const onProjectChange = vi.fn()
|
|
180
|
+
const onOverlayEdit = vi.fn()
|
|
181
|
+
const onSelectIds = vi.fn()
|
|
182
|
+
const { container } = render(
|
|
183
|
+
<Timeline
|
|
184
|
+
project={makeProject()}
|
|
185
|
+
clock={clock}
|
|
186
|
+
selectedIds={['clip-0']}
|
|
187
|
+
onSelectIds={onSelectIds}
|
|
188
|
+
onProjectChange={onProjectChange}
|
|
189
|
+
onOverlayEdit={onOverlayEdit}
|
|
190
|
+
/>,
|
|
191
|
+
)
|
|
192
|
+
focusTimelineRoot(container)
|
|
193
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
194
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
195
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
196
|
+
expect(onSelectIds).toHaveBeenCalledWith([])
|
|
197
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
198
|
+
expect(updated.tracks?.[0]?.items.find((i) => i.id === 'clip-0')).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// D1: a caption id can sit in `selectedIds` alongside a clip id (selected on
|
|
202
|
+
// the canvas timeline like any other item — see Timeline.tsx's
|
|
203
|
+
// handleSelectItem). `deleteSelection` only knows tracks/audio vocabulary, so
|
|
204
|
+
// the keymap action must strip selected caption segments itself, folded into
|
|
205
|
+
// the SAME onProjectChange/onOverlayEdit commit as the clip delete — one undo
|
|
206
|
+
// entry covering the mixed selection, not two.
|
|
207
|
+
it('Delete strips a selected caption segment AND a selected clip in one commit', () => {
|
|
208
|
+
const clock = createPlaybackClock(0)
|
|
209
|
+
const onProjectChange = vi.fn()
|
|
210
|
+
const onOverlayEdit = vi.fn()
|
|
211
|
+
const onSelectIds = vi.fn()
|
|
212
|
+
const { container } = render(
|
|
213
|
+
<Timeline
|
|
214
|
+
project={makeProjectWithCaptions()}
|
|
215
|
+
clock={clock}
|
|
216
|
+
selectedIds={['clip-0', 'cap-0']}
|
|
217
|
+
onSelectIds={onSelectIds}
|
|
218
|
+
onProjectChange={onProjectChange}
|
|
219
|
+
onOverlayEdit={onOverlayEdit}
|
|
220
|
+
/>,
|
|
221
|
+
)
|
|
222
|
+
focusTimelineRoot(container)
|
|
223
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
224
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
225
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
226
|
+
expect(onSelectIds).toHaveBeenCalledWith([])
|
|
227
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
228
|
+
expect(updated.tracks?.[0]?.items.find((i) => i.id === 'clip-0')).toBeUndefined()
|
|
229
|
+
expect(updated.captions?.segments.map((s) => s.id)).toEqual(['cap-1'])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// Deleting every remaining caption segment must leave `captions` as an
|
|
233
|
+
// empty-segments object, not null the whole track — nulling it is the
|
|
234
|
+
// sidebar's explicit "Remove all" action, not a side effect of Delete.
|
|
235
|
+
it('Delete leaves an empty captions object when the last segment is removed, never nulls the track', () => {
|
|
236
|
+
const clock = createPlaybackClock(0)
|
|
237
|
+
const onProjectChange = vi.fn()
|
|
238
|
+
const project = makeProjectWithCaptions()
|
|
239
|
+
project.captions!.segments = [{ id: 'cap-0', text: 'hello', start: 0, end: 1 }]
|
|
240
|
+
const { container } = render(
|
|
241
|
+
<Timeline
|
|
242
|
+
project={project}
|
|
243
|
+
clock={clock}
|
|
244
|
+
selectedIds={['cap-0']}
|
|
245
|
+
onSelectIds={vi.fn()}
|
|
246
|
+
onProjectChange={onProjectChange}
|
|
247
|
+
/>,
|
|
248
|
+
)
|
|
249
|
+
focusTimelineRoot(container)
|
|
250
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
251
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
252
|
+
expect(updated.captions).not.toBeNull()
|
|
253
|
+
expect(updated.captions?.segments).toEqual([])
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Caption rows: emptying one leaves a HOLE lane, and the row has to collapse
|
|
257
|
+
// in the SAME commit as the delete that emptied it — otherwise the operator's
|
|
258
|
+
// Cmd-Z brings the caption back but leaves the row renumbering behind (or
|
|
259
|
+
// undoes it separately, one keystroke later).
|
|
260
|
+
it('Delete collapses and renumbers a caption row it empties, in the same commit', () => {
|
|
261
|
+
const clock = createPlaybackClock(0)
|
|
262
|
+
const onProjectChange = vi.fn()
|
|
263
|
+
const onOverlayEdit = vi.fn()
|
|
264
|
+
const project = {
|
|
265
|
+
...makeProject(),
|
|
266
|
+
captions: {
|
|
267
|
+
style: 'clean',
|
|
268
|
+
segments: [
|
|
269
|
+
{ id: 'cap-0', text: 'ground', start: 0, end: 1 }, // lane 0
|
|
270
|
+
{ id: 'cap-1', text: 'middle', start: 0, end: 1, lane: 1 }, // lane 1, alone
|
|
271
|
+
{ id: 'cap-2', text: 'top', start: 0, end: 1, lane: 2 }, // lane 2
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
} as unknown as Project
|
|
275
|
+
const { container } = render(
|
|
276
|
+
<Timeline
|
|
277
|
+
project={project}
|
|
278
|
+
clock={clock}
|
|
279
|
+
selectedIds={['cap-1']}
|
|
280
|
+
onSelectIds={vi.fn()}
|
|
281
|
+
onProjectChange={onProjectChange}
|
|
282
|
+
onOverlayEdit={onOverlayEdit}
|
|
283
|
+
/>,
|
|
284
|
+
)
|
|
285
|
+
focusTimelineRoot(container)
|
|
286
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
287
|
+
// ONE commit — the strip and the renumbering are a single undo entry.
|
|
288
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
289
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
290
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
291
|
+
expect(updated.captions?.segments.map((s) => s.id)).toEqual(['cap-0', 'cap-2'])
|
|
292
|
+
// cap-2 was on lane 2 with a hole below it; it drops into the vacated row.
|
|
293
|
+
expect(updated.captions?.segments.map((s) => s.lane ?? 0)).toEqual([0, 1])
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('Delete leaves the other rows alone when the row it empties is the TOP one', () => {
|
|
297
|
+
// Removing the highest lane leaves no hole, so nothing renumbers.
|
|
298
|
+
const clock = createPlaybackClock(0)
|
|
299
|
+
const onProjectChange = vi.fn()
|
|
300
|
+
const project = {
|
|
301
|
+
...makeProject(),
|
|
302
|
+
captions: {
|
|
303
|
+
style: 'clean',
|
|
304
|
+
segments: [
|
|
305
|
+
{ id: 'cap-0', text: 'ground', start: 0, end: 1 },
|
|
306
|
+
{ id: 'cap-1', text: 'top', start: 0, end: 1, lane: 1 },
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
} as unknown as Project
|
|
310
|
+
const { container } = render(
|
|
311
|
+
<Timeline
|
|
312
|
+
project={project}
|
|
313
|
+
clock={clock}
|
|
314
|
+
selectedIds={['cap-1']}
|
|
315
|
+
onSelectIds={vi.fn()}
|
|
316
|
+
onProjectChange={onProjectChange}
|
|
317
|
+
/>,
|
|
318
|
+
)
|
|
319
|
+
focusTimelineRoot(container)
|
|
320
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
321
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
322
|
+
expect(updated.captions?.segments.map((s) => s.id)).toEqual(['cap-0'])
|
|
323
|
+
expect(updated.captions?.segments[0].lane).toBeUndefined()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('Delete does NOT delete the selection when focus is outside the timeline (restored pre-SP5 scoping)', () => {
|
|
327
|
+
const clock = createPlaybackClock(0)
|
|
328
|
+
const onProjectChange = vi.fn()
|
|
329
|
+
render(
|
|
330
|
+
<Timeline
|
|
331
|
+
project={makeProject()}
|
|
332
|
+
clock={clock}
|
|
333
|
+
selectedIds={['clip-0']}
|
|
334
|
+
onProjectChange={onProjectChange}
|
|
335
|
+
/>,
|
|
336
|
+
)
|
|
337
|
+
// Focus left on document.body (or anywhere outside Timeline's own root) —
|
|
338
|
+
// never inside the timeline. T9 briefly widened Delete/Enter to fire from
|
|
339
|
+
// anywhere on the page; this proves that regression stays fixed.
|
|
340
|
+
;(document.body as HTMLElement).focus()
|
|
341
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
342
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('Delete with no selection is a no-op', () => {
|
|
346
|
+
const clock = createPlaybackClock(0)
|
|
347
|
+
const onProjectChange = vi.fn()
|
|
348
|
+
render(<Timeline project={makeProject()} clock={clock} onProjectChange={onProjectChange} />)
|
|
349
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
350
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('Shift+Delete does NOT trigger Timeline\'s own delete binding (reserved for ripple-delete upstream)', () => {
|
|
354
|
+
const clock = createPlaybackClock(0)
|
|
355
|
+
const onProjectChange = vi.fn()
|
|
356
|
+
render(
|
|
357
|
+
<Timeline project={makeProject()} clock={clock} selectedIds={['clip-0']} onProjectChange={onProjectChange} />,
|
|
358
|
+
)
|
|
359
|
+
fireEvent.keyDown(document.body, { key: 'Delete', shiftKey: true })
|
|
360
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('exposes zoomFit through actionsRef for a host-level palette', () => {
|
|
364
|
+
const clock = createPlaybackClock(1)
|
|
365
|
+
const actionsRef: { current: TimelineActions | null } = { current: null }
|
|
366
|
+
render(<Timeline project={makeProject()} clock={clock} actionsRef={actionsRef} />)
|
|
367
|
+
expect(actionsRef.current).not.toBeNull()
|
|
368
|
+
act(() => { actionsRef.current!.zoomFit() })
|
|
369
|
+
// Smoke test: it doesn't throw, and the ref stays populated.
|
|
370
|
+
expect(actionsRef.current).not.toBeNull()
|
|
371
|
+
})
|
|
372
|
+
})
|