@bycrux/editor 0.12.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The fade-shape picker — a right-click (Vegas' own gesture) on an audio
|
|
4
|
+
* bar's fade GRIP opens a small menu of three ICON options (Linear /
|
|
5
|
+
* Logarithmic / Exponential — see `FadeCurveIcon` in Timeline.tsx), each
|
|
6
|
+
* naming its shape via `title`/`aria-label` rather than visible text;
|
|
7
|
+
* picking one commits `fadeInCurve`/`fadeOutCurve` as ONE undo entry.
|
|
8
|
+
* Options are selected here by their accessible NAME (`getByRole('button',
|
|
9
|
+
* {name: ...})`, which resolves off `aria-label`), not by visible text.
|
|
10
|
+
*
|
|
11
|
+
* `TimelineCanvas`'s `contextmenu` handler does its own hit-test and calls
|
|
12
|
+
* `onFadeCurveMenu` with CLIENT coordinates; `Timeline` owns the menu's
|
|
13
|
+
* open/closed state and renders it as a `position: fixed` DOM overlay. jsdom
|
|
14
|
+
* lays everything out at 0×0, so the surface's rect is stubbed to a real one
|
|
15
|
+
* via `installCanvasHarness` (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 { AudioTrack } from '../../../schema'
|
|
20
|
+
import type { Project } from '../../../types'
|
|
21
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
22
|
+
import Timeline from '../Timeline'
|
|
23
|
+
import { AUDIO_ITEM_INSET_PX, computeTimelineLayout } from '../canvas/draw'
|
|
24
|
+
import { installCanvasHarness, SURFACE_LEFT, timeToClientX } from './_canvasSelect'
|
|
25
|
+
|
|
26
|
+
let uninstall: () => void
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
uninstall = installCanvasHarness()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
cleanup()
|
|
34
|
+
uninstall()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const AUDIO_TRACK: AudioTrack = { id: 'a0', src: 'v.mp3', start: 0, end: 10, fadeIn: 2 }
|
|
38
|
+
|
|
39
|
+
/** `track` defaults to `AUDIO_TRACK` — only its `fadeInCurve`/`fadeOutCurve`
|
|
40
|
+
* ever varies between tests (e.g. the highlight test), never its timing, so
|
|
41
|
+
* `fadeInGripClientPoint` below can keep reading `AUDIO_TRACK`'s own
|
|
42
|
+
* start/fadeIn unconditionally. */
|
|
43
|
+
function makeProject(track: AudioTrack = AUDIO_TRACK): Project {
|
|
44
|
+
return {
|
|
45
|
+
id: 'p1',
|
|
46
|
+
status: 'draft',
|
|
47
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
48
|
+
audio: { tracks: [track] },
|
|
49
|
+
} as unknown as Project
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The fade-in grip's CLIENT (x, y) for `AUDIO_TRACK`, derived the same way
|
|
53
|
+
* `hit-test.ts`'s `audioFadeGripX`/`audioFadeGripZone` do — purely from
|
|
54
|
+
* time and the lane's own y, with no clip-body gutter math (the grip's HIT
|
|
55
|
+
* zone is defined in time, not pixels; see hit-test.ts). `timeToClientX`
|
|
56
|
+
* gives the SAME fit-to-view viewport the mounted surface settles to on
|
|
57
|
+
* render; only the audio-bar's own top inset (`AUDIO_ITEM_INSET_PX`) is
|
|
58
|
+
* genuinely fade-specific and stays local. */
|
|
59
|
+
function fadeInGripClientPoint(project: Project) {
|
|
60
|
+
const clientX = timeToClientX(project, AUDIO_TRACK.start + (AUDIO_TRACK.fadeIn ?? 0))
|
|
61
|
+
const lane = computeTimelineLayout(project).lanes[0]
|
|
62
|
+
const barTop = lane.y + AUDIO_ITEM_INSET_PX
|
|
63
|
+
return { clientX, clientY: barTop + 2 }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mount(track: AudioTrack = AUDIO_TRACK) {
|
|
67
|
+
const clock = createPlaybackClock()
|
|
68
|
+
const onProjectChange = vi.fn()
|
|
69
|
+
const onOverlayEdit = vi.fn()
|
|
70
|
+
const project = makeProject(track)
|
|
71
|
+
const utils = render(
|
|
72
|
+
<Timeline
|
|
73
|
+
project={project}
|
|
74
|
+
clock={clock}
|
|
75
|
+
onProjectChange={onProjectChange}
|
|
76
|
+
onOverlayEdit={onOverlayEdit}
|
|
77
|
+
/>,
|
|
78
|
+
)
|
|
79
|
+
const surface = utils.container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
80
|
+
return { clock, onProjectChange, onOverlayEdit, surface, project, ...utils }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The picker's three option buttons expose their shape as the ACCESSIBLE
|
|
84
|
+
* NAME (via `aria-label`, with a matching `title` for the hover tooltip) —
|
|
85
|
+
* never as visible text — so every lookup here goes through
|
|
86
|
+
* `getByRole('button', {name})` rather than `getByText`. */
|
|
87
|
+
function shapeButton(name: 'Linear' | 'Logarithmic' | 'Exponential') {
|
|
88
|
+
return screen.getByRole('button', { name })
|
|
89
|
+
}
|
|
90
|
+
function queryShapeButton(name: 'Linear' | 'Logarithmic' | 'Exponential') {
|
|
91
|
+
return screen.queryByRole('button', { name })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('Timeline — fade-shape picker (right-click a fade grip)', () => {
|
|
95
|
+
it('opens the picker with three shape ICON options on a fade-grip right-click', () => {
|
|
96
|
+
const { surface, project } = mount()
|
|
97
|
+
const point = fadeInGripClientPoint(project)
|
|
98
|
+
fireEvent.contextMenu(surface, point)
|
|
99
|
+
expect(shapeButton('Linear')).toBeInTheDocument()
|
|
100
|
+
expect(shapeButton('Logarithmic')).toBeInTheDocument()
|
|
101
|
+
expect(shapeButton('Exponential')).toBeInTheDocument()
|
|
102
|
+
// Icon-only: the shape name is discoverable via title/aria-label, not as
|
|
103
|
+
// rendered text content anywhere in the menu.
|
|
104
|
+
expect(screen.queryByText('Linear')).not.toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does NOT open the picker from a right-click elsewhere on the bar', () => {
|
|
108
|
+
const { surface } = mount()
|
|
109
|
+
// Well clear of the fade-in grip's small top-corner zone (see
|
|
110
|
+
// FADE_GRIP_ZONE_HEIGHT_PX) — the middle of the bar, vertically.
|
|
111
|
+
fireEvent.contextMenu(surface, { clientX: SURFACE_LEFT + 500, clientY: 100 })
|
|
112
|
+
expect(queryShapeButton('Linear')).not.toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('picking a shape commits fadeInCurve as ONE undo entry and closes the menu', () => {
|
|
116
|
+
const { surface, onProjectChange, onOverlayEdit, project } = mount()
|
|
117
|
+
const point = fadeInGripClientPoint(project)
|
|
118
|
+
fireEvent.contextMenu(surface, point)
|
|
119
|
+
|
|
120
|
+
fireEvent.click(shapeButton('Logarithmic'))
|
|
121
|
+
|
|
122
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
123
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
124
|
+
const committed = onOverlayEdit.mock.calls[0][0] as Project
|
|
125
|
+
expect(committed.audio?.tracks[0].fadeInCurve).toBe('log')
|
|
126
|
+
// fadeOutCurve is untouched — only the side that was right-clicked changes.
|
|
127
|
+
expect(committed.audio?.tracks[0].fadeOutCurve).toBeUndefined()
|
|
128
|
+
expect(queryShapeButton('Linear')).not.toBeInTheDocument()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('Escape closes the picker without committing anything', () => {
|
|
132
|
+
const { surface, onProjectChange, onOverlayEdit, project } = mount()
|
|
133
|
+
fireEvent.contextMenu(surface, fadeInGripClientPoint(project))
|
|
134
|
+
expect(shapeButton('Linear')).toBeInTheDocument()
|
|
135
|
+
|
|
136
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
137
|
+
|
|
138
|
+
expect(queryShapeButton('Linear')).not.toBeInTheDocument()
|
|
139
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
140
|
+
expect(onOverlayEdit).not.toHaveBeenCalled()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('clicking outside the menu closes it without touching the project', () => {
|
|
144
|
+
const { surface, onProjectChange, onOverlayEdit, project } = mount()
|
|
145
|
+
fireEvent.contextMenu(surface, fadeInGripClientPoint(project))
|
|
146
|
+
expect(shapeButton('Linear')).toBeInTheDocument()
|
|
147
|
+
|
|
148
|
+
// The full-viewport backdrop is what "click elsewhere" actually hits —
|
|
149
|
+
// fixed-positioned, so it's the element under the pointer wherever on
|
|
150
|
+
// screen the click lands.
|
|
151
|
+
fireEvent.click(screen.getByTestId('fade-curve-menu-backdrop'))
|
|
152
|
+
|
|
153
|
+
expect(queryShapeButton('Linear')).not.toBeInTheDocument()
|
|
154
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
155
|
+
expect(onOverlayEdit).not.toHaveBeenCalled()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('highlights the DEFAULT shape (exp) when no curve is set yet', () => {
|
|
159
|
+
const { surface, project } = mount()
|
|
160
|
+
fireEvent.contextMenu(surface, fadeInGripClientPoint(project))
|
|
161
|
+
expect(shapeButton('Exponential')).toHaveAttribute('aria-pressed', 'true')
|
|
162
|
+
expect(shapeButton('Linear')).toHaveAttribute('aria-pressed', 'false')
|
|
163
|
+
expect(shapeButton('Logarithmic')).toHaveAttribute('aria-pressed', 'false')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('highlights whichever shape is already set on the track being edited', () => {
|
|
167
|
+
const track: AudioTrack = { ...AUDIO_TRACK, fadeInCurve: 'log' }
|
|
168
|
+
const { surface, project } = mount(track)
|
|
169
|
+
fireEvent.contextMenu(surface, fadeInGripClientPoint(project))
|
|
170
|
+
expect(shapeButton('Logarithmic')).toHaveAttribute('aria-pressed', 'true')
|
|
171
|
+
expect(shapeButton('Linear')).toHaveAttribute('aria-pressed', 'false')
|
|
172
|
+
expect(shapeButton('Exponential')).toHaveAttribute('aria-pressed', 'false')
|
|
173
|
+
})
|
|
174
|
+
})
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* Delete removes the selected keyframe (SP9b T3.6) — the precedence seam
|
|
4
|
+
* between `timeline.delete-keyframe` and the pre-existing
|
|
5
|
+
* `timeline.delete-selection` binding (see `Timeline.keymap.test.tsx`).
|
|
6
|
+
* `useKeymap` is first-match-wins: with a keyframe diamond selected, Delete
|
|
7
|
+
* must remove ONLY that keyframe and leave the clip alone; with no keyframe
|
|
8
|
+
* selected it must fall straight through to the existing clip/caption/audio
|
|
9
|
+
* delete, unchanged.
|
|
10
|
+
*
|
|
11
|
+
* A keyframe diamond is selected via the REAL gesture — a double-click on it
|
|
12
|
+
* (`pointer-machine.ts`'s `doubleClick` case, `'keyframe'` hit → the
|
|
13
|
+
* `selectKeyframe` effect → `TimelineCanvas`'s `onSelectKeyframe` →
|
|
14
|
+
* `Timeline`'s `setSelectedKeyframe`) — mirroring how
|
|
15
|
+
* `Timeline.keyframeMenu.test.tsx` drives its own right-click gesture rather
|
|
16
|
+
* than reaching into Timeline's state directly.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
19
|
+
import { render, cleanup, fireEvent } from '@testing-library/react'
|
|
20
|
+
import type { Project } from '../../../types'
|
|
21
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
22
|
+
import Timeline from '../Timeline'
|
|
23
|
+
import { computeTimelineLayout } from '../canvas/draw'
|
|
24
|
+
import { canvasSurface, focusCanvasRoot, installCanvasHarness, timeToClientX } from './_canvasSelect'
|
|
25
|
+
|
|
26
|
+
let uninstall: () => void
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
uninstall = installCanvasHarness()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
cleanup()
|
|
34
|
+
uninstall()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* clip-0 (0s-2s, video, plain) + o0 (2s-4s, overlay) keyframed on `scale` at
|
|
39
|
+
* item-relative t=0.5 AND t=1.5. Two points on the one track that's ever
|
|
40
|
+
* touched by the "ordinary" tests below, so removing one point never hits
|
|
41
|
+
* the last-point/freeze branch — that branch gets its own dedicated fixture
|
|
42
|
+
* (`makeProjectWithFreezeCandidate`) rather than being an incidental side
|
|
43
|
+
* effect here.
|
|
44
|
+
*/
|
|
45
|
+
function makeProject(): Project {
|
|
46
|
+
return {
|
|
47
|
+
id: 'p1',
|
|
48
|
+
status: 'draft',
|
|
49
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
50
|
+
tracks: [{
|
|
51
|
+
id: 'trk-0',
|
|
52
|
+
items: [
|
|
53
|
+
{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2 },
|
|
54
|
+
{
|
|
55
|
+
id: 'o0', type: 'overlay', start: 2, end: 4,
|
|
56
|
+
keyframes: [
|
|
57
|
+
{ prop: 'scale', points: [{ t: 0.5, value: 1 }, { t: 1.5, value: 2 }] },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
}],
|
|
62
|
+
} as unknown as Project
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* o0 additionally keyframed on `opacity` with a SINGLE point at t=0.5 — the
|
|
67
|
+
* same instant `scale` also holds a point at, so the diamond there is the
|
|
68
|
+
* union of both props (`keyframeUnionTimes`). `opacity`'s stale static
|
|
69
|
+
* scalar (0.1) is deliberately far from the curve's value (0.4): if removal
|
|
70
|
+
* routed through the plain multi-point path instead of
|
|
71
|
+
* `removeKeyframesAt`'s last-point/`disableKeyframing` branch, the item would
|
|
72
|
+
* snap back to the stale 0.1 the instant the keyframe went away.
|
|
73
|
+
*/
|
|
74
|
+
function makeProjectWithFreezeCandidate(): Project {
|
|
75
|
+
const project = makeProject()
|
|
76
|
+
const overlay = project.tracks![0].items[1]
|
|
77
|
+
return {
|
|
78
|
+
...project,
|
|
79
|
+
tracks: [{
|
|
80
|
+
...project.tracks![0],
|
|
81
|
+
items: [
|
|
82
|
+
project.tracks![0].items[0],
|
|
83
|
+
{
|
|
84
|
+
...overlay,
|
|
85
|
+
opacity: 0.1,
|
|
86
|
+
keyframes: [
|
|
87
|
+
...overlay.keyframes!,
|
|
88
|
+
{ prop: 'opacity', points: [{ t: 0.5, value: 0.4 }] },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
}],
|
|
93
|
+
} as unknown as Project
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mount(project: Project, selectedIds: string[]) {
|
|
97
|
+
const clock = createPlaybackClock()
|
|
98
|
+
const onProjectChange = vi.fn()
|
|
99
|
+
const onOverlayEdit = vi.fn()
|
|
100
|
+
const onSelectIds = vi.fn()
|
|
101
|
+
const utils = render(
|
|
102
|
+
<Timeline
|
|
103
|
+
project={project}
|
|
104
|
+
clock={clock}
|
|
105
|
+
onProjectChange={onProjectChange}
|
|
106
|
+
onOverlayEdit={onOverlayEdit}
|
|
107
|
+
selectedIds={selectedIds}
|
|
108
|
+
onSelectIds={onSelectIds}
|
|
109
|
+
/>,
|
|
110
|
+
)
|
|
111
|
+
return { clock, onProjectChange, onOverlayEdit, onSelectIds, surface: canvasSurface(utils.container), ...utils }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** CLIENT (x, y) for o0's diamond at item-relative `t` — the same "fit-to-view
|
|
115
|
+
* viewport a freshly mounted surface settles to, plus a couple px above the
|
|
116
|
+
* row's bottom edge" math `Timeline.keyframeMenu.test.tsx`'s own
|
|
117
|
+
* `keyframeClientPoint` uses. */
|
|
118
|
+
function keyframeClientPoint(project: Project, t: number) {
|
|
119
|
+
const row = computeTimelineLayout(project).rows[0]
|
|
120
|
+
const clientX = timeToClientX(project, 2 + t) // o0.start is 2
|
|
121
|
+
return { clientX, clientY: row.y + row.height - 2 }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Select a keyframe diamond the honest way: a real double-click dispatched
|
|
125
|
+
* on the canvas surface, driving `pointer-machine.ts`'s `doubleClick` →
|
|
126
|
+
* `selectKeyframe` → `TimelineCanvas`'s `onSelectKeyframe` →
|
|
127
|
+
* `Timeline`'s `setSelectedKeyframe`. Never reaches into Timeline's state. */
|
|
128
|
+
function selectKeyframeAt(surface: HTMLElement, project: Project, t: number) {
|
|
129
|
+
fireEvent.doubleClick(surface, keyframeClientPoint(project, t))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe('Timeline — Delete removes the selected keyframe', () => {
|
|
133
|
+
it('Delete removes the selected keyframe and leaves the clip alone', () => {
|
|
134
|
+
const project = makeProject()
|
|
135
|
+
const { surface, container, onProjectChange, onOverlayEdit } = mount(project, ['o0'])
|
|
136
|
+
selectKeyframeAt(surface, project, 0.5)
|
|
137
|
+
focusCanvasRoot(container)
|
|
138
|
+
|
|
139
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
140
|
+
|
|
141
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
142
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
143
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
144
|
+
const items = updated.tracks![0].items
|
|
145
|
+
// Both items survive — nothing was deleted from the track.
|
|
146
|
+
expect(items.map(i => i.id)).toEqual(['clip-0', 'o0'])
|
|
147
|
+
const overlay = items.find(i => i.id === 'o0')!
|
|
148
|
+
const scaleTrack = overlay.keyframes!.find(t => t.prop === 'scale')!
|
|
149
|
+
// Only the t=0.5 point is gone; t=1.5 remains untouched.
|
|
150
|
+
expect(scaleTrack.points.map(p => p.t)).toEqual([1.5])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('Delete still deletes the clip when NO keyframe is selected', () => {
|
|
154
|
+
const project = makeProject()
|
|
155
|
+
const { surface: _surface, container, onProjectChange, onOverlayEdit, onSelectIds } = mount(project, ['clip-0'])
|
|
156
|
+
// No double-click — selectedKeyframe stays null, so the fall-through
|
|
157
|
+
// to `timeline.delete-selection` is exercised.
|
|
158
|
+
focusCanvasRoot(container)
|
|
159
|
+
|
|
160
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
161
|
+
|
|
162
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
163
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
164
|
+
expect(onSelectIds).toHaveBeenCalledWith([])
|
|
165
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
166
|
+
expect(updated.tracks![0].items.find(i => i.id === 'clip-0')).toBeUndefined()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('Delete removes only the keyframe, not the clip, when both are selected', () => {
|
|
170
|
+
// A keyframe can only be selected while its OWNING item is also selected
|
|
171
|
+
// (Timeline.tsx clears `selectedKeyframe` the instant its item leaves
|
|
172
|
+
// `selectedIds` — see the effect right after the `useState`), so "both
|
|
173
|
+
// selected" is the NORMAL case here, not a rare edge case. If
|
|
174
|
+
// `timeline.delete-keyframe` were ordered AFTER `timeline.delete-selection`
|
|
175
|
+
// (or its guard were wrong), first-match-wins would hand every one of
|
|
176
|
+
// these presses to the clip-delete binding instead, deleting o0 outright.
|
|
177
|
+
const project = makeProject()
|
|
178
|
+
const { surface, container, onProjectChange } = mount(project, ['o0'])
|
|
179
|
+
selectKeyframeAt(surface, project, 1.5)
|
|
180
|
+
focusCanvasRoot(container)
|
|
181
|
+
|
|
182
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
183
|
+
|
|
184
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
185
|
+
const items = updated.tracks![0].items
|
|
186
|
+
expect(items).toHaveLength(2)
|
|
187
|
+
expect(items.map(i => i.id)).toContain('o0')
|
|
188
|
+
const overlay = items.find(i => i.id === 'o0')!
|
|
189
|
+
const scaleTrack = overlay.keyframes!.find(t => t.prop === 'scale')
|
|
190
|
+
expect(scaleTrack?.points.map(p => p.t)).toEqual([0.5])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('clears the keyframe selection after removing it, so a second Delete hits the clip', () => {
|
|
194
|
+
const project = makeProject()
|
|
195
|
+
const { surface, container, onProjectChange, onSelectIds } = mount(project, ['o0'])
|
|
196
|
+
selectKeyframeAt(surface, project, 0.5)
|
|
197
|
+
focusCanvasRoot(container)
|
|
198
|
+
|
|
199
|
+
fireEvent.keyDown(document.body, { key: 'Delete' }) // removes the keyframe
|
|
200
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
201
|
+
// The FIRST press must NOT have touched the clip — this is what
|
|
202
|
+
// distinguishes "selection was cleared" from "Delete just deletes the
|
|
203
|
+
// clip regardless," which would make the second assertion below pass
|
|
204
|
+
// even if this whole feature were unwired.
|
|
205
|
+
const firstCommit = onProjectChange.mock.calls[0][0] as Project
|
|
206
|
+
expect(firstCommit.tracks![0].items.find(i => i.id === 'o0')).toBeDefined()
|
|
207
|
+
|
|
208
|
+
fireEvent.keyDown(document.body, { key: 'Delete' }) // second press: no keyframe selected anymore
|
|
209
|
+
expect(onProjectChange).toHaveBeenCalledTimes(2)
|
|
210
|
+
expect(onSelectIds).toHaveBeenCalledWith([])
|
|
211
|
+
const secondCommit = onProjectChange.mock.calls[1][0] as Project
|
|
212
|
+
expect(secondCommit.tracks![0].items.find(i => i.id === 'o0')).toBeUndefined()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('freezes the value when the removed keyframe was a track\'s last point', () => {
|
|
216
|
+
const project = makeProjectWithFreezeCandidate()
|
|
217
|
+
const { surface, container, onProjectChange } = mount(project, ['o0'])
|
|
218
|
+
// t=0.5 is the union of scale's (multi-point) and opacity's (single-point,
|
|
219
|
+
// "last point") tracks — one diamond, one Delete, two different removal
|
|
220
|
+
// branches inside `removeKeyframesAt` exercised at once.
|
|
221
|
+
selectKeyframeAt(surface, project, 0.5)
|
|
222
|
+
focusCanvasRoot(container)
|
|
223
|
+
|
|
224
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
225
|
+
|
|
226
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
227
|
+
const overlay = updated.tracks![0].items.find(i => i.id === 'o0')!
|
|
228
|
+
// opacity's only point is gone — its track is dropped entirely...
|
|
229
|
+
expect(overlay.keyframes?.find(t => t.prop === 'opacity')).toBeUndefined()
|
|
230
|
+
// ...and item.opacity holds the CURVE's sampled value at t=0.5 (0.4), not
|
|
231
|
+
// the stale pre-keyframing scalar (0.1) that was sitting there before.
|
|
232
|
+
expect(overlay.opacity).toBeCloseTo(0.4, 5)
|
|
233
|
+
// scale is unaffected: it had a SECOND point (t=1.5) at removal time, so
|
|
234
|
+
// it takes the ordinary multi-point branch and survives with that point.
|
|
235
|
+
const scaleTrack = overlay.keyframes!.find(t => t.prop === 'scale')!
|
|
236
|
+
expect(scaleTrack.points.map(p => p.t)).toEqual([1.5])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('falls through to clip deletion when the selected keyframe no longer exists', () => {
|
|
240
|
+
// The selection can go stale WITHOUT its item leaving `selectedIds` —
|
|
241
|
+
// a diamond drag, the right-click menu, undo. Nothing draws as selected
|
|
242
|
+
// then, so Delete must delete the clip rather than silently do nothing.
|
|
243
|
+
const project = makeProject()
|
|
244
|
+
const props = {
|
|
245
|
+
clock: createPlaybackClock(),
|
|
246
|
+
onProjectChange: vi.fn(),
|
|
247
|
+
onOverlayEdit: vi.fn(),
|
|
248
|
+
selectedIds: ['o0'],
|
|
249
|
+
onSelectIds: vi.fn(),
|
|
250
|
+
}
|
|
251
|
+
const { container, rerender } = render(<Timeline project={project} {...props} />)
|
|
252
|
+
selectKeyframeAt(canvasSurface(container), project, 0.5)
|
|
253
|
+
|
|
254
|
+
// Same item, still selected — but its t=0.5 keyframe is gone.
|
|
255
|
+
const pruned = {
|
|
256
|
+
...project,
|
|
257
|
+
tracks: [{
|
|
258
|
+
...project.tracks![0],
|
|
259
|
+
items: project.tracks![0].items.map(i => i.id === 'o0'
|
|
260
|
+
? { ...i, keyframes: [{ prop: 'scale', points: [{ t: 1.5, value: 2 }] }] }
|
|
261
|
+
: i),
|
|
262
|
+
}],
|
|
263
|
+
} as unknown as Project
|
|
264
|
+
rerender(<Timeline project={pruned} {...props} />)
|
|
265
|
+
focusCanvasRoot(container)
|
|
266
|
+
|
|
267
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
268
|
+
|
|
269
|
+
expect(props.onProjectChange).toHaveBeenCalledTimes(1)
|
|
270
|
+
const updated = props.onProjectChange.mock.calls[0][0] as Project
|
|
271
|
+
expect(updated.tracks![0].items.find(i => i.id === 'o0')).toBeUndefined()
|
|
272
|
+
})
|
|
273
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* Selection follows a retimed diamond (CapCut-correct operator decision):
|
|
4
|
+
* dragging the SELECTED diamond to a new time must keep it selected AT THAT
|
|
5
|
+
* NEW TIME — not drop the selection because `selectedKeyframe` still names
|
|
6
|
+
* the OLD `t`, which `moveKeyframe` has just vacated.
|
|
7
|
+
*
|
|
8
|
+
* Exercised end to end with a REAL drag (mousedown → mousemove(s) →
|
|
9
|
+
* mouseup on `document`, exactly as `TimelineCanvas` wires its own gesture —
|
|
10
|
+
* see `_canvasSelect.ts`'s `dragCanvasItem`), then a REAL `Delete` keypress,
|
|
11
|
+
* mirroring how `Timeline.keyframeDelete.test.tsx` proves selection through
|
|
12
|
+
* the actual gesture rather than by reaching into Timeline's state.
|
|
13
|
+
*
|
|
14
|
+
* `Harness` below is a CONTROLLED host, not a bare `<Timeline>` — it feeds
|
|
15
|
+
* `onProjectChange`/`onOverlayEdit` straight back into the `project` prop,
|
|
16
|
+
* exactly as the real host (`VideoEditor.tsx`) does. Without that, the mid-
|
|
17
|
+
* drag retime would never reach Timeline's own re-render, and the Delete
|
|
18
|
+
* guard (`keyframeUnionTimes(item).includes(selectedKeyframe.t)` — see
|
|
19
|
+
* Timeline.tsx) would check the followed `t` against the STALE, un-retimed
|
|
20
|
+
* project and (correctly, on its own terms) find nothing there — which
|
|
21
|
+
* would look identical to the bug this file exists to catch, for the wrong
|
|
22
|
+
* reason. `Timeline.keyframeDelete.test.tsx`'s own last test hits the same
|
|
23
|
+
* seam with a manual `rerender`; a controlled component is the equivalent
|
|
24
|
+
* for a gesture that retimes mid-test rather than being swapped in whole.
|
|
25
|
+
*
|
|
26
|
+
* Delete removing the point that's still there (not the whole clip, and not
|
|
27
|
+
* a no-op) is the only externally observable proof that `selectedKeyframe`
|
|
28
|
+
* followed the diamond — `pointer-machine.test.ts`'s "selection follows a
|
|
29
|
+
* retimed diamond" tests cover the effect itself, in isolation.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately NOT `fireEvent.pointerDown`/`pointerMove` — this repo's jsdom
|
|
32
|
+
* silently drops `clientX`/`clientY` off synthetic PointerEvents, which would
|
|
33
|
+
* make a drag never leave its press-time point without failing loudly. Raw
|
|
34
|
+
* `MouseEvent`s dispatched straight onto `document` (`TimelineCanvas` listens
|
|
35
|
+
* there for the life of a gesture) are what every other canvas gesture test
|
|
36
|
+
* in this package uses instead.
|
|
37
|
+
*/
|
|
38
|
+
import { useState } from 'react'
|
|
39
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
40
|
+
import { act, render, cleanup, fireEvent } from '@testing-library/react'
|
|
41
|
+
import type { Project } from '../../../types'
|
|
42
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
43
|
+
import Timeline from '../Timeline'
|
|
44
|
+
import { computeTimelineLayout } from '../canvas/draw'
|
|
45
|
+
import { canvasSurface, focusCanvasRoot, installCanvasHarness, timeToClientX } from './_canvasSelect'
|
|
46
|
+
|
|
47
|
+
let uninstall: () => void
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
uninstall = installCanvasHarness()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
cleanup()
|
|
55
|
+
uninstall()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* clip-0 (0s-2s, video, plain) + o0 (2s-4s, overlay) keyframed on `scale` at
|
|
60
|
+
* item-relative t=0.5 AND t=1.5 — the same fixture shape
|
|
61
|
+
* `Timeline.keyframeDelete.test.tsx` uses, so a diamond drag here lands on
|
|
62
|
+
* familiar ground: two points on one track, neither the union-of-props case.
|
|
63
|
+
*/
|
|
64
|
+
function makeProject(): Project {
|
|
65
|
+
return {
|
|
66
|
+
id: 'p1',
|
|
67
|
+
status: 'draft',
|
|
68
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
69
|
+
tracks: [{
|
|
70
|
+
id: 'trk-0',
|
|
71
|
+
items: [
|
|
72
|
+
{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2 },
|
|
73
|
+
{
|
|
74
|
+
id: 'o0', type: 'overlay', start: 2, end: 4,
|
|
75
|
+
keyframes: [
|
|
76
|
+
{ prop: 'scale', points: [{ t: 0.5, value: 1 }, { t: 1.5, value: 2 }] },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
}],
|
|
81
|
+
} as unknown as Project
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A controlled host: `project`/`selectedIds` live in real React state and
|
|
85
|
+
* are written back on every `onProjectChange`/`onOverlayEdit`/`onSelectIds`,
|
|
86
|
+
* same wiring `VideoEditor.tsx` gives Timeline in the real app. The spy
|
|
87
|
+
* functions record every call for assertions without replacing this
|
|
88
|
+
* feedback loop. */
|
|
89
|
+
function Harness(props: {
|
|
90
|
+
initial: Project
|
|
91
|
+
selectedIds: string[]
|
|
92
|
+
onProjectChange: (p: Project) => void
|
|
93
|
+
onOverlayEdit: (p: Project) => void
|
|
94
|
+
onSelectIds: (ids: string[]) => void
|
|
95
|
+
}) {
|
|
96
|
+
const [clock] = useState(() => createPlaybackClock())
|
|
97
|
+
const [project, setProject] = useState(props.initial)
|
|
98
|
+
const [selectedIds, setSelectedIds] = useState(props.selectedIds)
|
|
99
|
+
return (
|
|
100
|
+
<Timeline
|
|
101
|
+
project={project}
|
|
102
|
+
clock={clock}
|
|
103
|
+
onProjectChange={p => { setProject(p); props.onProjectChange(p) }}
|
|
104
|
+
onOverlayEdit={p => { setProject(p); props.onOverlayEdit(p) }}
|
|
105
|
+
selectedIds={selectedIds}
|
|
106
|
+
onSelectIds={ids => { setSelectedIds(ids); props.onSelectIds(ids) }}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mount(project: Project, selectedIds: string[]) {
|
|
112
|
+
const onProjectChange = vi.fn()
|
|
113
|
+
const onOverlayEdit = vi.fn()
|
|
114
|
+
const onSelectIds = vi.fn()
|
|
115
|
+
const utils = render(
|
|
116
|
+
<Harness
|
|
117
|
+
initial={project}
|
|
118
|
+
selectedIds={selectedIds}
|
|
119
|
+
onProjectChange={onProjectChange}
|
|
120
|
+
onOverlayEdit={onOverlayEdit}
|
|
121
|
+
onSelectIds={onSelectIds}
|
|
122
|
+
/>,
|
|
123
|
+
)
|
|
124
|
+
return { onProjectChange, onOverlayEdit, onSelectIds, surface: canvasSurface(utils.container), ...utils }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** CLIENT (x, y) for o0's diamond at item-relative `t` — the same math
|
|
128
|
+
* `Timeline.keyframeDelete.test.tsx`'s own `keyframeClientPoint` uses.
|
|
129
|
+
* Computed once, off the ORIGINAL project: `_canvasSelect.ts`'s viewport
|
|
130
|
+
* helpers fit to `computeDerivedTiming`, and neither this fixture's total
|
|
131
|
+
* duration nor its row layout changes as `o0`'s keyframes move, so the same
|
|
132
|
+
* coordinates stay valid for the whole test. */
|
|
133
|
+
function keyframeClientPoint(project: Project, t: number) {
|
|
134
|
+
const row = computeTimelineLayout(project).rows[0]
|
|
135
|
+
const clientX = timeToClientX(project, 2 + t) // o0.start is 2
|
|
136
|
+
return { clientX, clientY: row.y + row.height - 2 }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Select a keyframe diamond the honest way: a real double-click. */
|
|
140
|
+
function selectKeyframeAt(surface: HTMLElement, project: Project, t: number) {
|
|
141
|
+
fireEvent.doubleClick(surface, keyframeClientPoint(project, t))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mouseInit(p: { clientX: number; clientY: number }) {
|
|
145
|
+
return { clientX: p.clientX, clientY: p.clientY, button: 0, bubbles: true }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Drag o0's diamond at item-relative `fromT` by `dxPx` client pixels: press
|
|
149
|
+
* on the surface, two intermediate moves and a release dispatched straight
|
|
150
|
+
* onto `document` — `TimelineCanvas`'s own gesture wiring — mirroring
|
|
151
|
+
* `_canvasSelect.ts`'s `dragCanvasItem` (which cannot itself target a
|
|
152
|
+
* keyframe diamond; `resolveCanvasTarget` only resolves clips/audio/
|
|
153
|
+
* captions). `act` wraps the raw `document.dispatchEvent` calls for the
|
|
154
|
+
* same reason `dragCanvasItem`'s `dispatchOnDocument` does — those two
|
|
155
|
+
* aren't one of RTL's own wrapped fire helpers. */
|
|
156
|
+
function dragKeyframe(surface: HTMLElement, project: Project, fromT: number, dxPx: number) {
|
|
157
|
+
const from = keyframeClientPoint(project, fromT)
|
|
158
|
+
fireEvent.mouseDown(surface, mouseInit(from))
|
|
159
|
+
const mid = { clientX: from.clientX + dxPx / 2, clientY: from.clientY }
|
|
160
|
+
act(() => { document.dispatchEvent(new MouseEvent('mousemove', mouseInit(mid))) })
|
|
161
|
+
const to = { clientX: from.clientX + dxPx, clientY: from.clientY }
|
|
162
|
+
act(() => { document.dispatchEvent(new MouseEvent('mousemove', mouseInit(to))) })
|
|
163
|
+
act(() => { document.dispatchEvent(new MouseEvent('mouseup', mouseInit(to))) })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
describe('Timeline — keyframe selection follows a retimed diamond', () => {
|
|
167
|
+
it('Delete removes the point the diamond landed on after a drag, not the clip — proving the selection followed', () => {
|
|
168
|
+
const project = makeProject()
|
|
169
|
+
const { surface, container, onProjectChange } = mount(project, ['o0'])
|
|
170
|
+
selectKeyframeAt(surface, project, 0.5)
|
|
171
|
+
focusCanvasRoot(container)
|
|
172
|
+
|
|
173
|
+
// Drag the SELECTED t=0.5 diamond well clear of both its own start and
|
|
174
|
+
// the untouched t=1.5 point, so there is no ambiguity about which point
|
|
175
|
+
// Delete removed.
|
|
176
|
+
dragKeyframe(surface, project, 0.5, 30)
|
|
177
|
+
|
|
178
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
179
|
+
|
|
180
|
+
// The clip-delete fallback (`timeline.delete-selection`) would have
|
|
181
|
+
// removed 'o0' outright instead of just a keyframe point — it did not.
|
|
182
|
+
const updated = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
183
|
+
const items = updated.tracks![0].items
|
|
184
|
+
expect(items.map(i => i.id)).toEqual(['clip-0', 'o0'])
|
|
185
|
+
|
|
186
|
+
const overlay = items.find(i => i.id === 'o0')!
|
|
187
|
+
const scaleTrack = overlay.keyframes!.find(t => t.prop === 'scale')!
|
|
188
|
+
// Exactly one point survives: t=1.5, which the drag never touched. The
|
|
189
|
+
// point the drag moved to is gone — proof Delete's target followed the
|
|
190
|
+
// diamond rather than staying pinned to the vacated t=0.5 (which, with
|
|
191
|
+
// the bug, would have found nothing there and fallen through to
|
|
192
|
+
// deleting the whole clip instead — the assertion above already rules
|
|
193
|
+
// that out, this one pins down which point actually went).
|
|
194
|
+
expect(scaleTrack.points).toHaveLength(1)
|
|
195
|
+
expect(scaleTrack.points[0].t).toBeCloseTo(1.5)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('a second Delete now hits the clip — the followed selection was consumed, not left dangling', () => {
|
|
199
|
+
const project = makeProject()
|
|
200
|
+
const { surface, container, onProjectChange, onSelectIds } = mount(project, ['o0'])
|
|
201
|
+
selectKeyframeAt(surface, project, 0.5)
|
|
202
|
+
focusCanvasRoot(container)
|
|
203
|
+
|
|
204
|
+
dragKeyframe(surface, project, 0.5, 30)
|
|
205
|
+
|
|
206
|
+
fireEvent.keyDown(document.body, { key: 'Delete' }) // removes the followed keyframe
|
|
207
|
+
const afterFirst = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
208
|
+
expect(afterFirst.tracks![0].items.find(i => i.id === 'o0')).toBeDefined()
|
|
209
|
+
|
|
210
|
+
fireEvent.keyDown(document.body, { key: 'Delete' }) // no keyframe selected anymore
|
|
211
|
+
expect(onSelectIds).toHaveBeenCalledWith([])
|
|
212
|
+
const afterSecond = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
213
|
+
expect(afterSecond.tracks![0].items.find(i => i.id === 'o0')).toBeUndefined()
|
|
214
|
+
})
|
|
215
|
+
})
|