@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,1367 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
3
|
+
import type { KeyframeProp, VisualItem } from '../../schema'
|
|
4
|
+
import { createPlaybackClock } from '../playback-clock'
|
|
5
|
+
import { hasKeyframes, trackFor, valueAt } from '../keyframeOps'
|
|
6
|
+
import OverlayInspector, { alignedOffset } from '../OverlayInspector'
|
|
7
|
+
|
|
8
|
+
// Mirrors keyframeOps.test.ts's `overlay()` helper: start=5 deliberately
|
|
9
|
+
// non-zero, so any spot that treats absolute playhead time as item-relative
|
|
10
|
+
// `t` produces an obviously wrong number rather than passing by accident.
|
|
11
|
+
function overlayItem(over: Partial<VisualItem> = {}): VisualItem {
|
|
12
|
+
return {
|
|
13
|
+
id: 'o1',
|
|
14
|
+
type: 'overlay',
|
|
15
|
+
src: 'o.jsx',
|
|
16
|
+
start: 5,
|
|
17
|
+
end: 15,
|
|
18
|
+
props: {},
|
|
19
|
+
offsetX: 10,
|
|
20
|
+
offsetY: -5,
|
|
21
|
+
scale: 1.2,
|
|
22
|
+
rotation: 45,
|
|
23
|
+
opacity: 0.5,
|
|
24
|
+
...over,
|
|
25
|
+
} as VisualItem
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The header's all-props list for a UNIFORM item — what every suite below
|
|
29
|
+
* that drives the header diamond uses, since they all render uniform items.
|
|
30
|
+
* An item carrying scaleX/scaleY gets PER_AXIS_PROPS instead; see the
|
|
31
|
+
* "uniform scale lock" suite. Deliberately still hard-coded rather than
|
|
32
|
+
* imported: it is the contract the panel is being held to. */
|
|
33
|
+
const ALL_PROPS: KeyframeProp[] = ['offsetX', 'offsetY', 'scale', 'rotation', 'opacity']
|
|
34
|
+
const PER_AXIS_PROPS: KeyframeProp[] = ['offsetX', 'offsetY', 'scaleX', 'scaleY', 'rotation', 'opacity']
|
|
35
|
+
|
|
36
|
+
const LOCK = 'Uniform scale'
|
|
37
|
+
|
|
38
|
+
function renderInspector(
|
|
39
|
+
item: VisualItem | null,
|
|
40
|
+
playhead: number,
|
|
41
|
+
opts: { onSeek?: (time: number) => void } = {},
|
|
42
|
+
) {
|
|
43
|
+
const clock = createPlaybackClock(playhead)
|
|
44
|
+
const onPreview = vi.fn()
|
|
45
|
+
const onCommit = vi.fn()
|
|
46
|
+
const onChange = vi.fn()
|
|
47
|
+
const utils = render(
|
|
48
|
+
<OverlayInspector
|
|
49
|
+
item={item}
|
|
50
|
+
clock={clock}
|
|
51
|
+
onPreview={onPreview}
|
|
52
|
+
onCommit={onCommit}
|
|
53
|
+
onChange={onChange}
|
|
54
|
+
onSeek={opts.onSeek}
|
|
55
|
+
/>,
|
|
56
|
+
)
|
|
57
|
+
return { ...utils, clock, onPreview, onCommit, onChange }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The one item the header's keyframe-all "pressed" state needs: every
|
|
61
|
+
* transform prop animated, each with a point at item-relative t=0. */
|
|
62
|
+
function allKeyedItem(over: Partial<VisualItem> = {}): VisualItem {
|
|
63
|
+
return overlayItem({
|
|
64
|
+
keyframes: [
|
|
65
|
+
{ prop: 'offsetX', points: [{ t: 0, value: 10 }, { t: 10, value: 100 }] },
|
|
66
|
+
{ prop: 'offsetY', points: [{ t: 0, value: -5 }, { t: 10, value: 50 }] },
|
|
67
|
+
{ prop: 'scale', points: [{ t: 0, value: 1.2 }, { t: 10, value: 2 }] },
|
|
68
|
+
{ prop: 'rotation', points: [{ t: 0, value: 45 }, { t: 10, value: 90 }] },
|
|
69
|
+
{ prop: 'opacity', points: [{ t: 0, value: 0.5 }, { t: 10, value: 1 }] },
|
|
70
|
+
],
|
|
71
|
+
...over,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const HEADER_DIAMOND = 'Keyframe all transform properties at playhead'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fire a pointer event that actually CARRIES coordinates.
|
|
79
|
+
*
|
|
80
|
+
* jsdom ships no `PointerEvent`, so `fireEvent.pointerDown(el, { clientX })`
|
|
81
|
+
* falls back to a bare `Event` and silently drops clientX/clientY — the
|
|
82
|
+
* handler runs with `undefined` coordinates. A `MouseEvent` named
|
|
83
|
+
* `pointerdown` is what React's synthetic pointer event reads, and it does
|
|
84
|
+
* carry them. (`pointerId` is still undefined, which is why the dial's
|
|
85
|
+
* `setPointerCapture` call is optional — jsdom implements neither.)
|
|
86
|
+
*/
|
|
87
|
+
function firePointer(el: Element, type: 'pointerdown' | 'pointermove' | 'pointerup', clientX: number, clientY: number) {
|
|
88
|
+
fireEvent(el, new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY }))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dial = () => screen.getByRole('slider', { name: 'Rotation dial' })
|
|
92
|
+
|
|
93
|
+
/** jsdom lays nothing out, so every `getBoundingClientRect` is 0x0. Stand in
|
|
94
|
+
* a real 40x40 box at the origin so the dial's atan2 math is exercised. */
|
|
95
|
+
function stubDialRect(el: Element) {
|
|
96
|
+
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
|
97
|
+
left: 0, top: 0, right: 40, bottom: 40, width: 40, height: 40, x: 0, y: 0, toJSON: () => ({}),
|
|
98
|
+
} as DOMRect)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('OverlayInspector — rendering', () => {
|
|
102
|
+
it('renders a row per property with the current values', () => {
|
|
103
|
+
renderInspector(overlayItem(), 5) // localT = 0, all static
|
|
104
|
+
|
|
105
|
+
expect(screen.getByLabelText('Offset X')).toHaveValue(10)
|
|
106
|
+
expect(screen.getByLabelText('Offset Y')).toHaveValue(-5)
|
|
107
|
+
expect(screen.getByLabelText('Scale')).toHaveValue(120) // shown as a percentage now
|
|
108
|
+
expect(screen.getByLabelText('Rotation')).toHaveValue(45)
|
|
109
|
+
expect(screen.getByLabelText('Opacity')).toHaveValue(0.5)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('renders the empty state when nothing is selected', () => {
|
|
113
|
+
renderInspector(null, 5)
|
|
114
|
+
|
|
115
|
+
expect(screen.getByText('Select an overlay to edit its properties.')).toBeInTheDocument()
|
|
116
|
+
expect(screen.queryByLabelText('Offset X')).toBeNull()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('renders the panel for a VIDEO clip now that clips are keyframeable (SP9d)', () => {
|
|
120
|
+
const item = { id: 'v1', type: 'video', src: 'v.mp4', start: 0, end: 10 } as VisualItem
|
|
121
|
+
renderInspector(item, 5)
|
|
122
|
+
|
|
123
|
+
expect(screen.queryByText('Select an overlay to edit its properties.')).toBeNull()
|
|
124
|
+
expect(screen.queryByLabelText('Offset X')).not.toBeNull()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("disables a clip's OPACITY keyframe diamond, with a reason, rather than hiding it", () => {
|
|
128
|
+
// ffmpeg's colorchannelmixer takes its alpha gain as a <double> and accepts
|
|
129
|
+
// no expression, so clip opacity cannot animate. A missing control reads as
|
|
130
|
+
// a bug; a disabled one that says why reads as the limitation it is.
|
|
131
|
+
const item = { id: 'v1', type: 'video', src: 'v.mp4', start: 0, end: 10 } as VisualItem
|
|
132
|
+
renderInspector(item, 5)
|
|
133
|
+
|
|
134
|
+
const diamond = screen.getByLabelText(/Opacity keyframe at playhead/)
|
|
135
|
+
expect(diamond).toBeDisabled()
|
|
136
|
+
expect(diamond.getAttribute('title')).toMatch(/cannot be animated/i)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('leaves an overlay\'s opacity diamond fully enabled', () => {
|
|
140
|
+
renderInspector(overlayItem(), 5)
|
|
141
|
+
expect(screen.getByLabelText(/Opacity keyframe at playhead/)).not.toBeDisabled()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('renders the empty state for a kind that cannot be keyframed at all', () => {
|
|
145
|
+
const item = { id: 'a1', type: 'audio', src: 'a.wav', start: 0, end: 10 } as unknown as VisualItem
|
|
146
|
+
renderInspector(item, 5)
|
|
147
|
+
|
|
148
|
+
expect(screen.getByText('Select an overlay to edit its properties.')).toBeInTheDocument()
|
|
149
|
+
expect(screen.queryByLabelText('Offset X')).toBeNull()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('renders the transform body unconditionally (no title, no collapse toggle)', () => {
|
|
153
|
+
renderInspector(overlayItem(), 5)
|
|
154
|
+
|
|
155
|
+
// The pane carries no "Transform" heading (the CONTENT/TRANSFORM tab names
|
|
156
|
+
// it) and no collapse toggle — so there is no title text and nothing to
|
|
157
|
+
// click to hide the body.
|
|
158
|
+
expect(screen.queryByText('Transform')).toBeNull()
|
|
159
|
+
expect(screen.queryByRole('button', { name: 'Transform' })).toBeNull()
|
|
160
|
+
|
|
161
|
+
// The body is visible immediately, with no interaction required.
|
|
162
|
+
expect(screen.getByLabelText('Offset X')).toHaveValue(10)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('OverlayInspector — keyframe diamond', () => {
|
|
167
|
+
it('reflects keyframed state per property', () => {
|
|
168
|
+
const item = overlayItem({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1.2 }] }] })
|
|
169
|
+
renderInspector(item, 5)
|
|
170
|
+
|
|
171
|
+
expect(screen.getByRole('button', { name: /Remove Scale keyframe/ })).toHaveAttribute('aria-pressed', 'true')
|
|
172
|
+
expect(screen.getByRole('button', { name: /Add Rotation keyframe/ })).toHaveAttribute('aria-pressed', 'false')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('toggling an unkeyframed property enables keyframing without moving the overlay', () => {
|
|
176
|
+
const item = overlayItem() // scale: 1.2, static
|
|
177
|
+
const { onChange } = renderInspector(item, 5) // localT = 0
|
|
178
|
+
|
|
179
|
+
fireEvent.click(screen.getByRole('button', { name: /Add Scale keyframe/ }))
|
|
180
|
+
|
|
181
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
182
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
183
|
+
expect(hasKeyframes(next, 'scale')).toBe(true)
|
|
184
|
+
// The seeded keyframe must hold the SAME value the overlay already had —
|
|
185
|
+
// enabling keyframing must never move it.
|
|
186
|
+
expect(valueAt(next, 'scale', 0)).toBe(valueAt(item, 'scale', 0))
|
|
187
|
+
expect(valueAt(next, 'scale', 0)).toBe(1.2)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('toggling a keyframed property disables keyframing and freezes the current value', () => {
|
|
191
|
+
const item = overlayItem({
|
|
192
|
+
rotation: 999, // stale static field — disableKeyframing must overwrite it from the curve
|
|
193
|
+
keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
|
|
194
|
+
})
|
|
195
|
+
const { onChange } = renderInspector(item, 5) // localT = 0 -> curve value 0
|
|
196
|
+
|
|
197
|
+
fireEvent.click(screen.getByRole('button', { name: /Remove Rotation keyframe/ }))
|
|
198
|
+
|
|
199
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
200
|
+
expect(hasKeyframes(next, 'rotation')).toBe(false)
|
|
201
|
+
expect(next.rotation).toBe(0)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('OverlayInspector — auto-keyframe on change', () => {
|
|
206
|
+
it('writes the static scalar when the property is NOT keyframed', () => {
|
|
207
|
+
const item = overlayItem() // offsetX static
|
|
208
|
+
const { onPreview } = renderInspector(item, 5)
|
|
209
|
+
|
|
210
|
+
fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '42' } })
|
|
211
|
+
|
|
212
|
+
expect(onPreview).toHaveBeenCalledTimes(1)
|
|
213
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
214
|
+
expect(next.offsetX).toBe(42)
|
|
215
|
+
expect(hasKeyframes(next, 'offsetX')).toBe(false)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('writes a keyframe at (playhead - item.start) when the property IS keyframed', () => {
|
|
219
|
+
const item = overlayItem({
|
|
220
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
|
|
221
|
+
})
|
|
222
|
+
// item.start = 5, playhead = 8 -> localT = 3, a point NOT already on the track.
|
|
223
|
+
const { onPreview } = renderInspector(item, 8)
|
|
224
|
+
|
|
225
|
+
fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '77' } })
|
|
226
|
+
|
|
227
|
+
expect(onPreview).toHaveBeenCalledTimes(1)
|
|
228
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
229
|
+
const track = next.keyframes!.find(t => t.prop === 'offsetX')!
|
|
230
|
+
const point = track.points.find(p => p.t === 3)
|
|
231
|
+
expect(point).toBeDefined()
|
|
232
|
+
expect(point!.value).toBe(77)
|
|
233
|
+
// The original endpoints are untouched — this ADDS a keyframe, not replaces the track.
|
|
234
|
+
expect(track.points.find(p => p.t === 0)?.value).toBe(0)
|
|
235
|
+
expect(track.points.find(p => p.t === 10)?.value).toBe(100)
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// Same writeProp rule as the suite above, pinned for a CLIP specifically —
|
|
240
|
+
// clips are about to start using this panel (canKeyframe already accepts
|
|
241
|
+
// 'video'/'image', see the "renders the panel for a VIDEO clip" test), and
|
|
242
|
+
// nothing previously locked the static-vs-keyframe behavior in for that kind.
|
|
243
|
+
describe('OverlayInspector — auto-keyframe on change (video clip)', () => {
|
|
244
|
+
function clipItem(over: Partial<VisualItem> = {}): VisualItem {
|
|
245
|
+
return { id: 'v1', type: 'video', src: 'v.mp4', start: 0, end: 10, ...over } as VisualItem
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
it('writes the static scalar when the property is NOT keyframed', () => {
|
|
249
|
+
const item = clipItem({ rotation: 0 })
|
|
250
|
+
const { onPreview } = renderInspector(item, 5) // localT = 5
|
|
251
|
+
|
|
252
|
+
fireEvent.change(screen.getByLabelText('Rotation'), { target: { value: '45' } })
|
|
253
|
+
|
|
254
|
+
expect(onPreview).toHaveBeenCalledTimes(1)
|
|
255
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
256
|
+
expect(next.rotation).toBe(45)
|
|
257
|
+
expect(hasKeyframes(next, 'rotation')).toBe(false)
|
|
258
|
+
expect(next.keyframes).toBeUndefined() // no implicit keyframe track appears
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('writes a keyframe at the playhead when the property IS keyframed', () => {
|
|
262
|
+
const item = clipItem({
|
|
263
|
+
keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
|
|
264
|
+
})
|
|
265
|
+
const { onPreview } = renderInspector(item, 3) // localT = 3, not already on the track
|
|
266
|
+
|
|
267
|
+
fireEvent.change(screen.getByLabelText('Rotation'), { target: { value: '45' } })
|
|
268
|
+
|
|
269
|
+
expect(onPreview).toHaveBeenCalledTimes(1)
|
|
270
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
271
|
+
const track = next.keyframes!.find(t => t.prop === 'rotation')!
|
|
272
|
+
const point = track.points.find(p => p.t === 3)
|
|
273
|
+
expect(point).toBeDefined()
|
|
274
|
+
expect(point!.value).toBe(45)
|
|
275
|
+
// The static scalar is NOT overwritten — the write went to the curve instead.
|
|
276
|
+
expect(next.rotation).toBeUndefined()
|
|
277
|
+
// The original endpoints are untouched — this ADDS a keyframe, not replaces the track.
|
|
278
|
+
expect(track.points.find(p => p.t === 0)?.value).toBe(0)
|
|
279
|
+
expect(track.points.find(p => p.t === 10)?.value).toBe(90)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('OverlayInspector — clamped localT (selecting an overlay does not move the playhead)', () => {
|
|
284
|
+
// item.start = 5, item.end = 15 (overlayItem's defaults) — a 10s span.
|
|
285
|
+
it('clamps writes to t=0 when the playhead sits BEFORE the item start', () => {
|
|
286
|
+
const item = overlayItem({
|
|
287
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
|
|
288
|
+
})
|
|
289
|
+
// Unclamped this would be playhead(0) - start(5) = -5.
|
|
290
|
+
const { onPreview } = renderInspector(item, 0)
|
|
291
|
+
|
|
292
|
+
fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '42' } })
|
|
293
|
+
|
|
294
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
295
|
+
const track = next.keyframes!.find(t => t.prop === 'offsetX')!
|
|
296
|
+
expect(track.points.find(p => p.t === 0)?.value).toBe(42)
|
|
297
|
+
expect(track.points.some(p => p.t < 0)).toBe(false)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('clamps writes to t=item.end-item.start when the playhead sits AFTER the item end', () => {
|
|
301
|
+
const item = overlayItem({
|
|
302
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
|
|
303
|
+
})
|
|
304
|
+
// Unclamped this would be playhead(30) - start(5) = 25.
|
|
305
|
+
const { onPreview } = renderInspector(item, 30)
|
|
306
|
+
|
|
307
|
+
fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '77' } })
|
|
308
|
+
|
|
309
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
310
|
+
const track = next.keyframes!.find(t => t.prop === 'offsetX')!
|
|
311
|
+
expect(track.points.find(p => p.t === 10)?.value).toBe(77)
|
|
312
|
+
expect(track.points.some(p => p.t > 10)).toBe(false)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('leaves a playhead INSIDE the span unaffected by the clamp', () => {
|
|
316
|
+
const item = overlayItem({
|
|
317
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
|
|
318
|
+
})
|
|
319
|
+
const { onPreview } = renderInspector(item, 8) // localT = 3, well inside [0, 10]
|
|
320
|
+
|
|
321
|
+
fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '55' } })
|
|
322
|
+
|
|
323
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
324
|
+
const track = next.keyframes!.find(t => t.prop === 'offsetX')!
|
|
325
|
+
expect(track.points.find(p => p.t === 3)?.value).toBe(55)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('the keyframe diamond toggle also clamps its seed time to the span', () => {
|
|
329
|
+
const item = overlayItem() // scale: 1.2, unkeyframed
|
|
330
|
+
const { onChange } = renderInspector(item, 0) // playhead before start (start=5)
|
|
331
|
+
|
|
332
|
+
fireEvent.click(screen.getByRole('button', { name: /Add Scale keyframe/ }))
|
|
333
|
+
|
|
334
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
335
|
+
const track = next.keyframes!.find(t => t.prop === 'scale')!
|
|
336
|
+
expect(track.points).toEqual([{ t: 0, value: 1.2 }])
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('OverlayInspector — playhead scrubbing', () => {
|
|
341
|
+
it('updates a keyframed field’s displayed value as the playhead moves', () => {
|
|
342
|
+
const item = overlayItem({
|
|
343
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
|
|
344
|
+
})
|
|
345
|
+
const { clock } = renderInspector(item, 5) // localT = 0
|
|
346
|
+
|
|
347
|
+
expect(screen.getByLabelText('Offset X')).toHaveValue(0)
|
|
348
|
+
|
|
349
|
+
act(() => { clock.set(15) }) // localT = 10
|
|
350
|
+
|
|
351
|
+
expect(screen.getByLabelText('Offset X')).toHaveValue(100)
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
describe('OverlayInspector — a mid-typed value survives a playhead tick (FIX 10)', () => {
|
|
356
|
+
it('does not clobber a mid-typed value when the clock ticks before blur', () => {
|
|
357
|
+
const item = overlayItem() // offsetX: 10, static
|
|
358
|
+
const { clock } = renderInspector(item, 5)
|
|
359
|
+
const input = screen.getByLabelText('Offset X')
|
|
360
|
+
|
|
361
|
+
fireEvent.change(input, { target: { value: '4' } }) // mid-typing "4" of "42"
|
|
362
|
+
expect(input).toHaveValue(4)
|
|
363
|
+
|
|
364
|
+
// A tick arrives while the field is still mid-edit (unblurred) — this
|
|
365
|
+
// used to force the controlled input's value back to the stale prop,
|
|
366
|
+
// clobbering the keystroke.
|
|
367
|
+
act(() => { clock.set(6) })
|
|
368
|
+
expect(input).toHaveValue(4)
|
|
369
|
+
|
|
370
|
+
fireEvent.change(input, { target: { value: '42' } })
|
|
371
|
+
expect(input).toHaveValue(42)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('commits on blur, closing the typing gesture', () => {
|
|
375
|
+
const item = overlayItem()
|
|
376
|
+
const { onCommit } = renderInspector(item, 5)
|
|
377
|
+
const input = screen.getByLabelText('Offset X')
|
|
378
|
+
|
|
379
|
+
fireEvent.change(input, { target: { value: '42' } })
|
|
380
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
381
|
+
|
|
382
|
+
fireEvent.blur(input)
|
|
383
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('Enter commits it too, via the same blur path', () => {
|
|
387
|
+
const item = overlayItem()
|
|
388
|
+
const { onCommit } = renderInspector(item, 5)
|
|
389
|
+
const input = screen.getByLabelText('Offset X') as HTMLInputElement
|
|
390
|
+
|
|
391
|
+
input.focus()
|
|
392
|
+
fireEvent.change(input, { target: { value: '42' } })
|
|
393
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
394
|
+
|
|
395
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
396
|
+
expect(document.activeElement).not.toBe(input)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('after commit, the field goes back to tracking the live (now-updated) value', () => {
|
|
400
|
+
const item = overlayItem()
|
|
401
|
+
const { onCommit } = renderInspector(item, 5)
|
|
402
|
+
const input = screen.getByLabelText('Offset X')
|
|
403
|
+
|
|
404
|
+
fireEvent.change(input, { target: { value: '99' } })
|
|
405
|
+
fireEvent.blur(input)
|
|
406
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
407
|
+
// draft cleared -> falls back to the (still 10, since this test's parent
|
|
408
|
+
// never re-supplies a changed `item`) prop-derived value, proving blur
|
|
409
|
+
// actually released the draft rather than freezing the typed text forever.
|
|
410
|
+
expect(input).toHaveValue(10)
|
|
411
|
+
})
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
describe('OverlayInspector — scale slider', () => {
|
|
415
|
+
it('reflects the sampled scale as a percentage, in sync with the slider', () => {
|
|
416
|
+
renderInspector(overlayItem(), 5)
|
|
417
|
+
|
|
418
|
+
const slider = screen.getByRole('slider', { name: 'Scale slider' }) as HTMLInputElement
|
|
419
|
+
expect(slider.value).toBe('1.2')
|
|
420
|
+
expect(screen.getByLabelText('Scale')).toHaveValue(120) // 1.2x => 120%
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('tracks a keyframed scale as the playhead moves', () => {
|
|
424
|
+
const item = overlayItem({
|
|
425
|
+
keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 2 }] }],
|
|
426
|
+
})
|
|
427
|
+
const { clock } = renderInspector(item, 5) // localT = 0
|
|
428
|
+
const slider = screen.getByRole('slider', { name: 'Scale slider' }) as HTMLInputElement
|
|
429
|
+
|
|
430
|
+
expect(slider.value).toBe('0.5')
|
|
431
|
+
|
|
432
|
+
act(() => { clock.set(15) }) // localT = 10
|
|
433
|
+
|
|
434
|
+
expect(slider.value).toBe('2')
|
|
435
|
+
expect(screen.getByLabelText('Scale')).toHaveValue(200) // 2x => 200%
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('previews the slider value on every change', () => {
|
|
439
|
+
const { onPreview } = renderInspector(overlayItem(), 5)
|
|
440
|
+
const slider = screen.getByRole('slider', { name: 'Scale slider' })
|
|
441
|
+
|
|
442
|
+
fireEvent.change(slider, { target: { value: '1.5' } })
|
|
443
|
+
fireEvent.change(slider, { target: { value: '1.6' } })
|
|
444
|
+
|
|
445
|
+
expect(onPreview).toHaveBeenCalledTimes(2)
|
|
446
|
+
expect((onPreview.mock.calls[0][0] as VisualItem).scale).toBe(1.5)
|
|
447
|
+
expect((onPreview.mock.calls[1][0] as VisualItem).scale).toBe(1.6)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('commits ONCE on pointer up, not on every change, and not again on the trailing blur', () => {
|
|
451
|
+
const { onCommit } = renderInspector(overlayItem(), 5)
|
|
452
|
+
const slider = screen.getByRole('slider', { name: 'Scale slider' })
|
|
453
|
+
|
|
454
|
+
fireEvent.change(slider, { target: { value: '1.5' } })
|
|
455
|
+
fireEvent.change(slider, { target: { value: '1.6' } })
|
|
456
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
457
|
+
|
|
458
|
+
fireEvent.pointerUp(slider)
|
|
459
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
460
|
+
|
|
461
|
+
// A pointerup is normally followed by a blur — that must not stack a
|
|
462
|
+
// second undo entry onto the same gesture.
|
|
463
|
+
fireEvent.blur(slider)
|
|
464
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('commits a keyboard adjustment on key up', () => {
|
|
468
|
+
const { onCommit } = renderInspector(overlayItem(), 5)
|
|
469
|
+
const slider = screen.getByRole('slider', { name: 'Scale slider' })
|
|
470
|
+
|
|
471
|
+
fireEvent.change(slider, { target: { value: '1.3' } })
|
|
472
|
+
fireEvent.keyUp(slider, { key: 'ArrowRight' })
|
|
473
|
+
|
|
474
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('obeys the auto-keyframe rule like every other control', () => {
|
|
478
|
+
const item = overlayItem({
|
|
479
|
+
keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 2 }] }],
|
|
480
|
+
})
|
|
481
|
+
const { onPreview } = renderInspector(item, 8) // localT = 3
|
|
482
|
+
const slider = screen.getByRole('slider', { name: 'Scale slider' })
|
|
483
|
+
|
|
484
|
+
fireEvent.change(slider, { target: { value: '1.5' } })
|
|
485
|
+
|
|
486
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
487
|
+
expect(trackFor(next, 'scale')!.points.find(p => p.t === 3)?.value).toBe(1.5)
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
describe('OverlayInspector — uniform scale lock', () => {
|
|
492
|
+
it('renders checked and ENABLED for an item with no per-axis scale', () => {
|
|
493
|
+
renderInspector(overlayItem(), 5)
|
|
494
|
+
|
|
495
|
+
const lock = screen.getByRole('checkbox', { name: LOCK })
|
|
496
|
+
expect(lock).toHaveAttribute('aria-checked', 'true')
|
|
497
|
+
expect(lock).not.toHaveAttribute('aria-disabled')
|
|
498
|
+
expect(lock).toBeEnabled()
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('renders UNCHECKED for an item that carries scaleX/scaleY', () => {
|
|
502
|
+
renderInspector(overlayItem({ scaleX: 1.2, scaleY: 0.8 }), 5)
|
|
503
|
+
|
|
504
|
+
expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it('reads unchecked off a per-axis KEYFRAME TRACK, not just the scalar', () => {
|
|
508
|
+
const item = overlayItem({
|
|
509
|
+
keyframes: [{ prop: 'scaleX', points: [{ t: 0, value: 1 }, { t: 10, value: 2 }] }],
|
|
510
|
+
})
|
|
511
|
+
renderInspector(item, 5)
|
|
512
|
+
|
|
513
|
+
expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('stays unchecked for a deliberately-unlocked item whose axes happen to be equal', () => {
|
|
517
|
+
// Equality is NOT the test — `scaleX === scaleY` on an item the operator
|
|
518
|
+
// unlocked on purpose must not silently re-lock it.
|
|
519
|
+
renderInspector(overlayItem({ scaleX: 1.2, scaleY: 1.2 }), 5)
|
|
520
|
+
|
|
521
|
+
expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('re-derives its state when a DIFFERENT overlay is selected', () => {
|
|
525
|
+
// The reason the state is derived from `item` rather than held in
|
|
526
|
+
// useState: this panel is reconciled in place across selection changes, so
|
|
527
|
+
// component state would keep showing the previous overlay's answer.
|
|
528
|
+
const clock = createPlaybackClock(5)
|
|
529
|
+
const props = { clock, onPreview: vi.fn(), onCommit: vi.fn(), onChange: vi.fn() }
|
|
530
|
+
const { rerender } = render(<OverlayInspector item={overlayItem({ scaleX: 2, scaleY: 0.5 })} {...props} />)
|
|
531
|
+
expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
|
|
532
|
+
|
|
533
|
+
rerender(<OverlayInspector item={overlayItem({ id: 'o2' })} {...props} />)
|
|
534
|
+
|
|
535
|
+
expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'true')
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('is operable from the keyboard', () => {
|
|
539
|
+
const { onChange } = renderInspector(overlayItem(), 5)
|
|
540
|
+
|
|
541
|
+
// role=checkbox on a <button> fires click for Space/Enter natively; jsdom
|
|
542
|
+
// does not synthesize that, so the click IS the keyboard activation here.
|
|
543
|
+
// What this asserts is that nothing disables the control or swallows it.
|
|
544
|
+
const lock = screen.getByRole('checkbox', { name: LOCK })
|
|
545
|
+
lock.focus()
|
|
546
|
+
expect(lock).toHaveFocus()
|
|
547
|
+
fireEvent.click(lock)
|
|
548
|
+
|
|
549
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
describe('unlocking (ON → OFF)', () => {
|
|
553
|
+
it('splits the Scale row into Scale X / Scale Y boxes', () => {
|
|
554
|
+
renderInspector(overlayItem({ scaleX: 1.2, scaleY: 0.8 }), 5)
|
|
555
|
+
|
|
556
|
+
expect(screen.getByLabelText('Scale X')).toHaveValue(120)
|
|
557
|
+
expect(screen.getByLabelText('Scale Y')).toHaveValue(80)
|
|
558
|
+
// The single uniform row — box AND slider — is gone.
|
|
559
|
+
expect(screen.queryByLabelText('Scale')).toBeNull()
|
|
560
|
+
expect(screen.queryByRole('slider', { name: 'Scale slider' })).toBeNull()
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('seeds BOTH axes from the current scale, so nothing jumps', () => {
|
|
564
|
+
const item = overlayItem({ scale: 1.2 })
|
|
565
|
+
const { onChange } = renderInspector(item, 5) // localT = 0
|
|
566
|
+
|
|
567
|
+
fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
|
|
568
|
+
|
|
569
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
570
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
571
|
+
expect(next.scaleX).toBe(1.2)
|
|
572
|
+
expect(next.scaleY).toBe(1.2)
|
|
573
|
+
// The actual no-jump proof: the EFFECTIVE per-axis scale the renderer
|
|
574
|
+
// resolves is identical either side of the transition.
|
|
575
|
+
expect(valueAt(next, 'scaleX', 0)).toBe(valueAt(item, 'scaleX', 0))
|
|
576
|
+
expect(valueAt(next, 'scaleY', 0)).toBe(valueAt(item, 'scaleY', 0))
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('seeds from the ANIMATED scale at the playhead, not the static scalar', () => {
|
|
580
|
+
const item = overlayItem({
|
|
581
|
+
scale: 999, // stale scalar the track shadows — seeding from it would jump
|
|
582
|
+
keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 10, value: 2 }] }],
|
|
583
|
+
})
|
|
584
|
+
const { onChange } = renderInspector(item, 10) // localT = 5, halfway => 1.5
|
|
585
|
+
|
|
586
|
+
fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
|
|
587
|
+
|
|
588
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
589
|
+
expect(next.scaleX).toBe(1.5)
|
|
590
|
+
expect(next.scaleY).toBe(1.5)
|
|
591
|
+
expect(valueAt(next, 'scaleX', 5)).toBe(valueAt(item, 'scaleX', 5))
|
|
592
|
+
expect(valueAt(next, 'scaleY', 5)).toBe(valueAt(item, 'scaleY', 5))
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it('writes the per-axis boxes back as multipliers', () => {
|
|
596
|
+
const { onPreview } = renderInspector(overlayItem({ scaleX: 1, scaleY: 1 }), 5)
|
|
597
|
+
|
|
598
|
+
fireEvent.change(screen.getByLabelText('Scale X'), { target: { value: '200' } })
|
|
599
|
+
fireEvent.change(screen.getByLabelText('Scale Y'), { target: { value: '50' } })
|
|
600
|
+
|
|
601
|
+
expect((onPreview.mock.calls[0][0] as VisualItem).scaleX).toBe(2)
|
|
602
|
+
expect((onPreview.mock.calls[1][0] as VisualItem).scaleY).toBe(0.5)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('steps each axis independently', () => {
|
|
606
|
+
const { onChange } = renderInspector(overlayItem({ scaleX: 1.2, scaleY: 1.2 }), 5)
|
|
607
|
+
|
|
608
|
+
fireEvent.click(screen.getByRole('button', { name: 'Increase Scale X' }))
|
|
609
|
+
expect((onChange.mock.calls[0][0] as VisualItem).scaleX).toBe(1.21)
|
|
610
|
+
|
|
611
|
+
fireEvent.click(screen.getByRole('button', { name: 'Decrease Scale Y' }))
|
|
612
|
+
expect((onChange.mock.calls[1][0] as VisualItem).scaleY).toBe(1.19)
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
describe('locking (OFF → ON)', () => {
|
|
617
|
+
it('collapses to `scale` keeping the X value, and clears both axes', () => {
|
|
618
|
+
const item = overlayItem({ scaleX: 1.5, scaleY: 0.25 })
|
|
619
|
+
const { onChange } = renderInspector(item, 5)
|
|
620
|
+
|
|
621
|
+
fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
|
|
622
|
+
|
|
623
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
624
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
625
|
+
expect(next.scale).toBe(1.5) // X wins — arbitrary but documented and stable
|
|
626
|
+
expect('scaleX' in next).toBe(false)
|
|
627
|
+
expect('scaleY' in next).toBe(false)
|
|
628
|
+
// And the resolver agrees: both axes fall back to the uniform value.
|
|
629
|
+
expect(valueAt(next, 'scaleX', 0)).toBe(1.5)
|
|
630
|
+
expect(valueAt(next, 'scaleY', 0)).toBe(1.5)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('clears per-axis keyframe TRACKS too, not just the scalars', () => {
|
|
634
|
+
const item = overlayItem({
|
|
635
|
+
scaleX: 9,
|
|
636
|
+
keyframes: [
|
|
637
|
+
{ prop: 'scaleX', points: [{ t: 0, value: 1.5 }, { t: 10, value: 3 }] },
|
|
638
|
+
{ prop: 'scaleY', points: [{ t: 0, value: 0.25 }, { t: 10, value: 4 }] },
|
|
639
|
+
{ prop: 'opacity', points: [{ t: 0, value: 0.5 }] },
|
|
640
|
+
],
|
|
641
|
+
})
|
|
642
|
+
const { onChange } = renderInspector(item, 5) // localT = 0
|
|
643
|
+
|
|
644
|
+
fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
|
|
645
|
+
|
|
646
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
647
|
+
expect(next.scale).toBe(1.5) // the X CURVE's value at the playhead
|
|
648
|
+
expect('scaleX' in next).toBe(false)
|
|
649
|
+
expect(trackFor(next, 'scaleX')).toBeUndefined()
|
|
650
|
+
expect(trackFor(next, 'scaleY')).toBeUndefined()
|
|
651
|
+
// An unrelated track is untouched — this clears two props, not the item.
|
|
652
|
+
expect(trackFor(next, 'opacity')!.points).toEqual([{ t: 0, value: 0.5 }])
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
it('drops `item.keyframes` entirely when the per-axis tracks were the only ones', () => {
|
|
656
|
+
const item = overlayItem({
|
|
657
|
+
keyframes: [
|
|
658
|
+
{ prop: 'scaleX', points: [{ t: 0, value: 1.5 }] },
|
|
659
|
+
{ prop: 'scaleY', points: [{ t: 0, value: 0.5 }] },
|
|
660
|
+
],
|
|
661
|
+
})
|
|
662
|
+
const { onChange } = renderInspector(item, 5)
|
|
663
|
+
|
|
664
|
+
fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
|
|
665
|
+
|
|
666
|
+
// Not a lingering `[]` — downstream treats "no keyframes" as the static
|
|
667
|
+
// path and the two must behave identically (withTrack's contract).
|
|
668
|
+
expect((onChange.mock.calls[0][0] as VisualItem).keyframes).toBeUndefined()
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('keyframes `scale` instead of writing the scalar when it is already animated', () => {
|
|
672
|
+
// The collapse is a normal panel write, so writeProp's auto-keyframe rule
|
|
673
|
+
// applies to it exactly as it does to typing in the Scale box.
|
|
674
|
+
const item = overlayItem({
|
|
675
|
+
scaleX: 2,
|
|
676
|
+
keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 1 }] }],
|
|
677
|
+
})
|
|
678
|
+
const { onChange } = renderInspector(item, 8) // localT = 3
|
|
679
|
+
|
|
680
|
+
fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
|
|
681
|
+
|
|
682
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
683
|
+
expect(trackFor(next, 'scale')!.points.find(p => p.t === 3)?.value).toBe(2)
|
|
684
|
+
expect('scaleX' in next).toBe(false)
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it('round-trips: unlock then lock returns the original scale', () => {
|
|
688
|
+
const item = overlayItem({ scale: 1.2 })
|
|
689
|
+
const first = renderInspector(item, 5)
|
|
690
|
+
fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
|
|
691
|
+
const unlocked = first.onChange.mock.calls[0][0] as VisualItem
|
|
692
|
+
first.unmount()
|
|
693
|
+
|
|
694
|
+
const second = renderInspector(unlocked, 5)
|
|
695
|
+
fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
|
|
696
|
+
const relocked = second.onChange.mock.calls[0][0] as VisualItem
|
|
697
|
+
|
|
698
|
+
expect(relocked.scale).toBe(1.2)
|
|
699
|
+
expect('scaleX' in relocked).toBe(false)
|
|
700
|
+
expect('scaleY' in relocked).toBe(false)
|
|
701
|
+
})
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
describe('per-axis keyframes', () => {
|
|
705
|
+
it('gives each axis its OWN keyframe unit', () => {
|
|
706
|
+
renderInspector(overlayItem({ scaleX: 1, scaleY: 1 }), 5)
|
|
707
|
+
|
|
708
|
+
expect(screen.getByRole('button', { name: 'Add Scale X keyframe at playhead' })).toBeInTheDocument()
|
|
709
|
+
expect(screen.getByRole('button', { name: 'Add Scale Y keyframe at playhead' })).toBeInTheDocument()
|
|
710
|
+
expect(screen.getByRole('button', { name: 'Previous Scale X keyframe' })).toBeInTheDocument()
|
|
711
|
+
expect(screen.getByRole('button', { name: 'Next Scale Y keyframe' })).toBeInTheDocument()
|
|
712
|
+
// The uniform row's diamond is gone with the row.
|
|
713
|
+
expect(screen.queryByRole('button', { name: 'Add Scale keyframe at playhead' })).toBeNull()
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
it('a diamond animates ONE axis, leaving the other static', () => {
|
|
717
|
+
const { onChange } = renderInspector(overlayItem({ scaleX: 1.5, scaleY: 0.5 }), 8) // localT = 3
|
|
718
|
+
|
|
719
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add Scale X keyframe at playhead' }))
|
|
720
|
+
|
|
721
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
722
|
+
expect(trackFor(next, 'scaleX')!.points).toEqual([{ t: 3, value: 1.5 }])
|
|
723
|
+
expect(hasKeyframes(next, 'scaleY')).toBe(false)
|
|
724
|
+
expect(next.scaleY).toBe(0.5)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('a pressed diamond turns that axis back into a static scalar', () => {
|
|
728
|
+
const item = overlayItem({
|
|
729
|
+
scaleX: 999, // stale — disableKeyframing must overwrite it, not restore it
|
|
730
|
+
keyframes: [{ prop: 'scaleX', points: [{ t: 0, value: 1.5 }] }],
|
|
731
|
+
})
|
|
732
|
+
const { onChange } = renderInspector(item, 5) // localT = 0
|
|
733
|
+
|
|
734
|
+
const diamond = screen.getByRole('button', { name: 'Remove Scale X keyframe at playhead' })
|
|
735
|
+
expect(diamond).toHaveAttribute('aria-pressed', 'true')
|
|
736
|
+
fireEvent.click(diamond)
|
|
737
|
+
|
|
738
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
739
|
+
expect(trackFor(next, 'scaleX')).toBeUndefined()
|
|
740
|
+
expect(next.scaleX).toBe(1.5) // nothing moves
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
it('the per-axis boxes keyframe (in percent) when that axis is animated', () => {
|
|
744
|
+
const item = overlayItem({
|
|
745
|
+
keyframes: [{ prop: 'scaleY', points: [{ t: 0, value: 0.5 }, { t: 10, value: 2 }] }],
|
|
746
|
+
})
|
|
747
|
+
const { onPreview } = renderInspector(item, 8) // localT = 3
|
|
748
|
+
|
|
749
|
+
fireEvent.change(screen.getByLabelText('Scale Y'), { target: { value: '150' } })
|
|
750
|
+
|
|
751
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
752
|
+
expect(trackFor(next, 'scaleY')!.points.find(p => p.t === 3)?.value).toBe(1.5)
|
|
753
|
+
})
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
describe('the header all-props actions follow the lock', () => {
|
|
757
|
+
it('keyframe-all on a UNIFORM item keys `scale` and never seeds scaleX/scaleY', () => {
|
|
758
|
+
// The whole point of the two-list split: a one-point scaleX track would
|
|
759
|
+
// shadow the `scale` track and silently freeze the uniform zoom.
|
|
760
|
+
const item = overlayItem({
|
|
761
|
+
keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 10, value: 2 }] }],
|
|
762
|
+
})
|
|
763
|
+
const { onChange } = renderInspector(item, 8) // localT = 3
|
|
764
|
+
|
|
765
|
+
fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
|
|
766
|
+
|
|
767
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
768
|
+
expect(trackFor(next, 'scale')!.points.some(p => p.t === 3)).toBe(true)
|
|
769
|
+
expect(trackFor(next, 'scaleX')).toBeUndefined()
|
|
770
|
+
expect(trackFor(next, 'scaleY')).toBeUndefined()
|
|
771
|
+
// The uniform animation still animates.
|
|
772
|
+
expect(valueAt(next, 'scaleX', 0)).toBe(1)
|
|
773
|
+
expect(valueAt(next, 'scaleX', 10)).toBe(2)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('keyframe-all on an UNLOCKED item keys both axes and not `scale`', () => {
|
|
777
|
+
const item = overlayItem({ scaleX: 1.5, scaleY: 0.5 })
|
|
778
|
+
const { onChange } = renderInspector(item, 8) // localT = 3
|
|
779
|
+
|
|
780
|
+
fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
|
|
781
|
+
|
|
782
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
783
|
+
for (const prop of PER_AXIS_PROPS) {
|
|
784
|
+
expect(trackFor(next, prop)!.points.some(p => p.t === 3)).toBe(true)
|
|
785
|
+
expect(valueAt(next, prop, 3)).toBe(valueAt(item, prop, 3))
|
|
786
|
+
}
|
|
787
|
+
expect(trackFor(next, 'scale')).toBeUndefined()
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
it('reset-all on an UNLOCKED item resets the axes, not the shadowed `scale`', () => {
|
|
791
|
+
const item = overlayItem({ scale: 1.2, scaleX: 3, scaleY: 0.25 })
|
|
792
|
+
const { onChange } = renderInspector(item, 5)
|
|
793
|
+
|
|
794
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reset transform' }))
|
|
795
|
+
|
|
796
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
797
|
+
expect(next.scaleX).toBe(1)
|
|
798
|
+
expect(next.scaleY).toBe(1)
|
|
799
|
+
expect(next.scale).toBe(1.2) // untouched, and shadowed anyway
|
|
800
|
+
// Reset is not a re-lock: the item stays authored per-axis.
|
|
801
|
+
expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
it('the header diamond reflects both axes on an unlocked item', () => {
|
|
805
|
+
const keyed = (props: KeyframeProp[]) =>
|
|
806
|
+
overlayItem({ scaleX: 1, scaleY: 1, keyframes: props.map(prop => ({ prop, points: [{ t: 0, value: 1 }] })) })
|
|
807
|
+
|
|
808
|
+
const { unmount } = renderInspector(keyed(PER_AXIS_PROPS), 5) // localT = 0
|
|
809
|
+
expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'true')
|
|
810
|
+
unmount()
|
|
811
|
+
|
|
812
|
+
// Everything but scaleY: the header must NOT read as fully keyed.
|
|
813
|
+
renderInspector(keyed(PER_AXIS_PROPS.filter(p => p !== 'scaleY')), 5)
|
|
814
|
+
expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'false')
|
|
815
|
+
})
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
it('the scale box takes a percentage and writes it back as a multiplier', () => {
|
|
819
|
+
const { onPreview } = renderInspector(overlayItem(), 5)
|
|
820
|
+
|
|
821
|
+
fireEvent.change(screen.getByLabelText('Scale'), { target: { value: '200' } })
|
|
822
|
+
|
|
823
|
+
expect(onPreview).toHaveBeenCalledTimes(1)
|
|
824
|
+
expect((onPreview.mock.calls[0][0] as VisualItem).scale).toBe(2)
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
it('the scale box keyframes `scale` (in percent) when it is animated', () => {
|
|
828
|
+
const item = overlayItem({
|
|
829
|
+
keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 2 }] }],
|
|
830
|
+
})
|
|
831
|
+
const { onPreview } = renderInspector(item, 8) // localT = 3
|
|
832
|
+
|
|
833
|
+
fireEvent.change(screen.getByLabelText('Scale'), { target: { value: '150' } })
|
|
834
|
+
|
|
835
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
836
|
+
expect(trackFor(next, 'scale')!.points.find(p => p.t === 3)?.value).toBe(1.5)
|
|
837
|
+
})
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
describe('OverlayInspector — steppers', () => {
|
|
841
|
+
it('nudges up by the row step in one discrete change', () => {
|
|
842
|
+
const { onChange, onPreview, onCommit } = renderInspector(overlayItem(), 5)
|
|
843
|
+
|
|
844
|
+
fireEvent.click(screen.getByRole('button', { name: 'Increase Offset X' }))
|
|
845
|
+
|
|
846
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
847
|
+
expect(onPreview).not.toHaveBeenCalled()
|
|
848
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
849
|
+
expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(11)
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
it('nudges down by the row step', () => {
|
|
853
|
+
const { onChange } = renderInspector(overlayItem(), 5)
|
|
854
|
+
|
|
855
|
+
fireEvent.click(screen.getByRole('button', { name: 'Decrease Offset X' }))
|
|
856
|
+
|
|
857
|
+
expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(9)
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
it('rounds away float noise (1.2 + 0.01 is not 1.2100000000000002)', () => {
|
|
861
|
+
const { onChange } = renderInspector(overlayItem(), 5)
|
|
862
|
+
|
|
863
|
+
fireEvent.click(screen.getByRole('button', { name: 'Increase Scale' }))
|
|
864
|
+
|
|
865
|
+
expect((onChange.mock.calls[0][0] as VisualItem).scale).toBe(1.21)
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
it('clamps at the row max', () => {
|
|
869
|
+
const { onChange } = renderInspector(overlayItem({ opacity: 1 }), 5)
|
|
870
|
+
|
|
871
|
+
fireEvent.click(screen.getByRole('button', { name: 'Increase Opacity' }))
|
|
872
|
+
|
|
873
|
+
expect((onChange.mock.calls[0][0] as VisualItem).opacity).toBe(1)
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
it('clamps at the row min', () => {
|
|
877
|
+
const { onChange } = renderInspector(overlayItem({ scale: 0.01 }), 5)
|
|
878
|
+
|
|
879
|
+
fireEvent.click(screen.getByRole('button', { name: 'Decrease Scale' }))
|
|
880
|
+
|
|
881
|
+
expect((onChange.mock.calls[0][0] as VisualItem).scale).toBe(0.01)
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
it('writes a keyframe at the playhead when the prop is keyframed', () => {
|
|
885
|
+
const item = overlayItem({
|
|
886
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
|
|
887
|
+
})
|
|
888
|
+
const { onChange } = renderInspector(item, 8) // localT = 3 -> sampled 30
|
|
889
|
+
|
|
890
|
+
fireEvent.click(screen.getByRole('button', { name: 'Increase Offset X' }))
|
|
891
|
+
|
|
892
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
893
|
+
expect(trackFor(next, 'offsetX')!.points.find(p => p.t === 3)?.value).toBe(31)
|
|
894
|
+
})
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
describe('OverlayInspector — rotation dial', () => {
|
|
898
|
+
it('exposes the current rotation as aria-valuenow, tracking the playhead', () => {
|
|
899
|
+
const item = overlayItem({
|
|
900
|
+
keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
|
|
901
|
+
})
|
|
902
|
+
const { clock } = renderInspector(item, 5) // localT = 0
|
|
903
|
+
const dial = screen.getByRole('slider', { name: 'Rotation dial' })
|
|
904
|
+
|
|
905
|
+
expect(dial).toHaveAttribute('aria-valuemin', '0')
|
|
906
|
+
expect(dial).toHaveAttribute('aria-valuemax', '360')
|
|
907
|
+
expect(dial).toHaveAttribute('aria-valuenow', '0')
|
|
908
|
+
|
|
909
|
+
act(() => { clock.set(10) }) // localT = 5 -> halfway -> 45
|
|
910
|
+
expect(dial).toHaveAttribute('aria-valuenow', '45')
|
|
911
|
+
|
|
912
|
+
act(() => { clock.set(15) }) // localT = 10
|
|
913
|
+
expect(dial).toHaveAttribute('aria-valuenow', '90')
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
it('nudges one degree per arrow key', () => {
|
|
917
|
+
const { onChange } = renderInspector(overlayItem(), 5) // rotation 45
|
|
918
|
+
const dial = screen.getByRole('slider', { name: 'Rotation dial' })
|
|
919
|
+
|
|
920
|
+
fireEvent.keyDown(dial, { key: 'ArrowRight' })
|
|
921
|
+
expect((onChange.mock.calls[0][0] as VisualItem).rotation).toBe(46)
|
|
922
|
+
|
|
923
|
+
fireEvent.keyDown(dial, { key: 'ArrowLeft' })
|
|
924
|
+
expect((onChange.mock.calls[1][0] as VisualItem).rotation).toBe(44)
|
|
925
|
+
|
|
926
|
+
fireEvent.keyDown(dial, { key: 'ArrowUp' })
|
|
927
|
+
expect((onChange.mock.calls[2][0] as VisualItem).rotation).toBe(46)
|
|
928
|
+
|
|
929
|
+
fireEvent.keyDown(dial, { key: 'ArrowDown' })
|
|
930
|
+
expect((onChange.mock.calls[3][0] as VisualItem).rotation).toBe(44)
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
it('Shift multiplies the nudge to 15 degrees', () => {
|
|
934
|
+
const { onChange } = renderInspector(overlayItem(), 5) // rotation 45
|
|
935
|
+
const dial = screen.getByRole('slider', { name: 'Rotation dial' })
|
|
936
|
+
|
|
937
|
+
fireEvent.keyDown(dial, { key: 'ArrowRight', shiftKey: true })
|
|
938
|
+
expect((onChange.mock.calls[0][0] as VisualItem).rotation).toBe(60)
|
|
939
|
+
|
|
940
|
+
fireEvent.keyDown(dial, { key: 'ArrowLeft', shiftKey: true })
|
|
941
|
+
expect((onChange.mock.calls[1][0] as VisualItem).rotation).toBe(30)
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
it('wraps into [0, 360) rather than going negative', () => {
|
|
945
|
+
const { onChange } = renderInspector(overlayItem({ rotation: 0 }), 5)
|
|
946
|
+
const dial = screen.getByRole('slider', { name: 'Rotation dial' })
|
|
947
|
+
|
|
948
|
+
fireEvent.keyDown(dial, { key: 'ArrowLeft' })
|
|
949
|
+
|
|
950
|
+
expect((onChange.mock.calls[0][0] as VisualItem).rotation).toBe(359)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
it('ignores keys it does not handle', () => {
|
|
954
|
+
const { onChange } = renderInspector(overlayItem(), 5)
|
|
955
|
+
const dial = screen.getByRole('slider', { name: 'Rotation dial' })
|
|
956
|
+
|
|
957
|
+
fireEvent.keyDown(dial, { key: 'a' })
|
|
958
|
+
fireEvent.keyDown(dial, { key: 'Enter' })
|
|
959
|
+
|
|
960
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
it('writes the static scalar when rotation is NOT keyframed', () => {
|
|
964
|
+
const { onChange } = renderInspector(overlayItem(), 5)
|
|
965
|
+
|
|
966
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Rotation dial' }), { key: 'ArrowRight' })
|
|
967
|
+
|
|
968
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
969
|
+
expect(next.rotation).toBe(46)
|
|
970
|
+
expect(hasKeyframes(next, 'rotation')).toBe(false)
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
it('writes a keyframe at localT when rotation IS keyframed', () => {
|
|
974
|
+
const item = overlayItem({
|
|
975
|
+
keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
|
|
976
|
+
})
|
|
977
|
+
const { onChange } = renderInspector(item, 8) // localT = 3 -> sampled 27
|
|
978
|
+
|
|
979
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Rotation dial' }), { key: 'ArrowRight' })
|
|
980
|
+
|
|
981
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
982
|
+
const track = trackFor(next, 'rotation')!
|
|
983
|
+
expect(track.points.find(p => p.t === 3)?.value).toBe(28)
|
|
984
|
+
expect(track.points.find(p => p.t === 0)?.value).toBe(0)
|
|
985
|
+
expect(track.points.find(p => p.t === 10)?.value).toBe(90)
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
it('emits nothing from a drag on a zero-size rect rather than a NaN', () => {
|
|
989
|
+
const { onPreview, onCommit } = renderInspector(overlayItem(), 5)
|
|
990
|
+
const el = dial()
|
|
991
|
+
// No stubDialRect here: the real jsdom rect is 0x0, exactly the case
|
|
992
|
+
// `angleFrom` bails on. A NaN reaching `setKeyframe` would be swallowed by
|
|
993
|
+
// its finite guard, but the STATIC path (`{ ...item, rotation: NaN }`) has
|
|
994
|
+
// no such guard, so bailing here is what actually protects the item.
|
|
995
|
+
firePointer(el, 'pointerdown', 30, 10)
|
|
996
|
+
firePointer(el, 'pointermove', 40, 20)
|
|
997
|
+
firePointer(el, 'pointerup', 40, 20)
|
|
998
|
+
|
|
999
|
+
expect(onPreview).not.toHaveBeenCalled()
|
|
1000
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
it('maps a pointer to degrees clockwise-from-up once the rect has a size', () => {
|
|
1004
|
+
const { onPreview, onCommit } = renderInspector(overlayItem(), 5)
|
|
1005
|
+
const el = dial()
|
|
1006
|
+
stubDialRect(el)
|
|
1007
|
+
|
|
1008
|
+
const degrees = () => {
|
|
1009
|
+
const calls = onPreview.mock.calls
|
|
1010
|
+
return (calls[calls.length - 1][0] as VisualItem).rotation
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
firePointer(el, 'pointerdown', 20, 0) // straight up -> 0
|
|
1014
|
+
expect(degrees()).toBe(0)
|
|
1015
|
+
|
|
1016
|
+
firePointer(el, 'pointermove', 40, 20) // right -> 90
|
|
1017
|
+
expect(degrees()).toBe(90)
|
|
1018
|
+
|
|
1019
|
+
firePointer(el, 'pointermove', 20, 40) // down -> 180
|
|
1020
|
+
expect(degrees()).toBe(180)
|
|
1021
|
+
|
|
1022
|
+
firePointer(el, 'pointermove', 0, 20) // left -> 270, not -90
|
|
1023
|
+
expect(degrees()).toBe(270)
|
|
1024
|
+
|
|
1025
|
+
// The drag is ONE gesture: previews throughout, a single commit at the end.
|
|
1026
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
1027
|
+
firePointer(el, 'pointerup', 0, 20)
|
|
1028
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
it('keyframes the dragged angle when rotation is keyframed', () => {
|
|
1032
|
+
const item = overlayItem({
|
|
1033
|
+
keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
|
|
1034
|
+
})
|
|
1035
|
+
const { onPreview } = renderInspector(item, 8) // localT = 3
|
|
1036
|
+
const el = dial()
|
|
1037
|
+
stubDialRect(el)
|
|
1038
|
+
|
|
1039
|
+
firePointer(el, 'pointerdown', 40, 20) // right -> 90
|
|
1040
|
+
|
|
1041
|
+
const next = onPreview.mock.calls[0][0] as VisualItem
|
|
1042
|
+
expect(trackFor(next, 'rotation')!.points.find(p => p.t === 3)?.value).toBe(90)
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
it('ignores a pointer move with no preceding pointer down', () => {
|
|
1046
|
+
const { onPreview } = renderInspector(overlayItem(), 5)
|
|
1047
|
+
const el = dial()
|
|
1048
|
+
stubDialRect(el)
|
|
1049
|
+
|
|
1050
|
+
firePointer(el, 'pointermove', 40, 20)
|
|
1051
|
+
|
|
1052
|
+
expect(onPreview).not.toHaveBeenCalled()
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
it('does not spend an undo entry on a pointer up that never previewed', () => {
|
|
1056
|
+
const { onCommit } = renderInspector(overlayItem(), 5)
|
|
1057
|
+
const el = dial()
|
|
1058
|
+
stubDialRect(el)
|
|
1059
|
+
|
|
1060
|
+
// Pressing exactly on the centre has no direction, so nothing is emitted;
|
|
1061
|
+
// releasing must not then commit an edit that never happened.
|
|
1062
|
+
firePointer(el, 'pointerdown', 20, 20)
|
|
1063
|
+
firePointer(el, 'pointerup', 20, 20)
|
|
1064
|
+
|
|
1065
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
1066
|
+
})
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
describe('alignedOffset', () => {
|
|
1070
|
+
it('puts a scaled-down overlay’s edge on the frame edge', () => {
|
|
1071
|
+
expect(alignedOffset(0.5, -1)).toBe(-25)
|
|
1072
|
+
expect(alignedOffset(0.5, 0)).toBe(0)
|
|
1073
|
+
expect(alignedOffset(0.5, 1)).toBe(25)
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
it('collapses to centered at scale 1, where the overlay exactly fills the frame', () => {
|
|
1077
|
+
expect(alignedOffset(1, -1)).toBe(0)
|
|
1078
|
+
expect(alignedOffset(1, 0)).toBe(0)
|
|
1079
|
+
expect(alignedOffset(1, 1)).toBe(0)
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
it('clamps an over-scaled overlay to centered rather than pushing it further off-frame', () => {
|
|
1083
|
+
expect(alignedOffset(2, -1)).toBe(0)
|
|
1084
|
+
expect(alignedOffset(2, 0)).toBe(0)
|
|
1085
|
+
expect(alignedOffset(2, 1)).toBe(0)
|
|
1086
|
+
})
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
describe('OverlayInspector — align row', () => {
|
|
1090
|
+
const CASES: { label: string; prop: 'offsetX' | 'offsetY'; expected: number }[] = [
|
|
1091
|
+
{ label: 'Align left', prop: 'offsetX', expected: -25 },
|
|
1092
|
+
{ label: 'Align center', prop: 'offsetX', expected: 0 },
|
|
1093
|
+
{ label: 'Align right', prop: 'offsetX', expected: 25 },
|
|
1094
|
+
{ label: 'Align top', prop: 'offsetY', expected: -25 },
|
|
1095
|
+
{ label: 'Align middle', prop: 'offsetY', expected: 0 },
|
|
1096
|
+
{ label: 'Align bottom', prop: 'offsetY', expected: 25 },
|
|
1097
|
+
]
|
|
1098
|
+
|
|
1099
|
+
for (const { label, prop, expected } of CASES) {
|
|
1100
|
+
it(`${label} writes ${expected} on ${prop} and leaves the other axis alone`, () => {
|
|
1101
|
+
const item = overlayItem({ scale: 0.5 }) // edge offset magnitude = 25
|
|
1102
|
+
const { onChange, unmount } = renderInspector(item, 5)
|
|
1103
|
+
|
|
1104
|
+
fireEvent.click(screen.getByRole('button', { name: label }))
|
|
1105
|
+
|
|
1106
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
1107
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
1108
|
+
expect(next[prop]).toBe(expected)
|
|
1109
|
+
const other = prop === 'offsetX' ? 'offsetY' : 'offsetX'
|
|
1110
|
+
expect(next[other]).toBe(item[other])
|
|
1111
|
+
unmount()
|
|
1112
|
+
})
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
it('uses the SAMPLED scale mid-animation, not the stale static scalar', () => {
|
|
1116
|
+
const item = overlayItem({
|
|
1117
|
+
scale: 999, // never read: the prop is keyframed
|
|
1118
|
+
keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 0.5 }] }],
|
|
1119
|
+
})
|
|
1120
|
+
const { onChange } = renderInspector(item, 8) // localT = 3 -> scale 0.5
|
|
1121
|
+
|
|
1122
|
+
fireEvent.click(screen.getByRole('button', { name: 'Align right' }))
|
|
1123
|
+
|
|
1124
|
+
expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(25)
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
it('keyframes the offset when the offset prop is keyframed', () => {
|
|
1128
|
+
const item = overlayItem({
|
|
1129
|
+
scale: 0.5,
|
|
1130
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
|
|
1131
|
+
})
|
|
1132
|
+
const { onChange } = renderInspector(item, 8) // localT = 3
|
|
1133
|
+
|
|
1134
|
+
fireEvent.click(screen.getByRole('button', { name: 'Align left' }))
|
|
1135
|
+
|
|
1136
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
1137
|
+
expect(trackFor(next, 'offsetX')!.points.find(p => p.t === 3)?.value).toBe(-25)
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
it('uses the X scale for horizontal alignment and the Y scale for vertical', () => {
|
|
1141
|
+
// A non-uniform overlay's left edge is set by its WIDTH and its top edge by
|
|
1142
|
+
// its HEIGHT, so one shared scale would put one of the two axes wrong.
|
|
1143
|
+
const item = overlayItem({ scaleX: 0.5, scaleY: 0.25 })
|
|
1144
|
+
const { onChange } = renderInspector(item, 5)
|
|
1145
|
+
|
|
1146
|
+
fireEvent.click(screen.getByRole('button', { name: 'Align left' }))
|
|
1147
|
+
expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(-25) // (1 - 0.5) / 2
|
|
1148
|
+
|
|
1149
|
+
fireEvent.click(screen.getByRole('button', { name: 'Align bottom' }))
|
|
1150
|
+
expect((onChange.mock.calls[1][0] as VisualItem).offsetY).toBe(37.5) // (1 - 0.25) / 2
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
it('samples each axis independently mid-animation', () => {
|
|
1154
|
+
const item = overlayItem({
|
|
1155
|
+
scaleX: 999,
|
|
1156
|
+
scaleY: 999,
|
|
1157
|
+
keyframes: [
|
|
1158
|
+
{ prop: 'scaleX', points: [{ t: 0, value: 0.5 }, { t: 10, value: 0.5 }] },
|
|
1159
|
+
{ prop: 'scaleY', points: [{ t: 0, value: 0.2 }, { t: 10, value: 0.2 }] },
|
|
1160
|
+
],
|
|
1161
|
+
})
|
|
1162
|
+
const { onChange } = renderInspector(item, 8) // localT = 3
|
|
1163
|
+
|
|
1164
|
+
fireEvent.click(screen.getByRole('button', { name: 'Align right' }))
|
|
1165
|
+
expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(25)
|
|
1166
|
+
|
|
1167
|
+
fireEvent.click(screen.getByRole('button', { name: 'Align top' }))
|
|
1168
|
+
expect((onChange.mock.calls[1][0] as VisualItem).offsetY).toBe(-40)
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
it('is unchanged for a legacy uniform item, where both axes resolve to `scale`', () => {
|
|
1172
|
+
const item = overlayItem({ scale: 0.5 })
|
|
1173
|
+
const { onChange } = renderInspector(item, 5)
|
|
1174
|
+
|
|
1175
|
+
fireEvent.click(screen.getByRole('button', { name: 'Align top' }))
|
|
1176
|
+
|
|
1177
|
+
expect((onChange.mock.calls[0][0] as VisualItem).offsetY).toBe(-25)
|
|
1178
|
+
})
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
describe('OverlayInspector — reset', () => {
|
|
1182
|
+
it('writes all five defaults in a single change', () => {
|
|
1183
|
+
const { onChange } = renderInspector(overlayItem(), 5)
|
|
1184
|
+
|
|
1185
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reset transform' }))
|
|
1186
|
+
|
|
1187
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
1188
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
1189
|
+
expect(next.offsetX).toBe(0)
|
|
1190
|
+
expect(next.offsetY).toBe(0)
|
|
1191
|
+
expect(next.scale).toBe(1)
|
|
1192
|
+
expect(next.rotation).toBe(0)
|
|
1193
|
+
expect(next.opacity).toBe(1)
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
it('drops a default-valued keyframe on a keyframed prop instead of deleting the track', () => {
|
|
1197
|
+
const item = overlayItem({
|
|
1198
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
|
|
1199
|
+
})
|
|
1200
|
+
const { onChange } = renderInspector(item, 8) // localT = 3
|
|
1201
|
+
|
|
1202
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reset transform' }))
|
|
1203
|
+
|
|
1204
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
1205
|
+
expect(hasKeyframes(next, 'offsetX')).toBe(true)
|
|
1206
|
+
const track = trackFor(next, 'offsetX')!
|
|
1207
|
+
expect(track.points.find(p => p.t === 3)?.value).toBe(0)
|
|
1208
|
+
// The animation itself survives — reset is non-destructive.
|
|
1209
|
+
expect(track.points.find(p => p.t === 0)?.value).toBe(0)
|
|
1210
|
+
expect(track.points.find(p => p.t === 10)?.value).toBe(100)
|
|
1211
|
+
// Unkeyframed props still take the plain scalar.
|
|
1212
|
+
expect(next.scale).toBe(1)
|
|
1213
|
+
expect(hasKeyframes(next, 'scale')).toBe(false)
|
|
1214
|
+
})
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
describe('OverlayInspector — header keyframe navigation', () => {
|
|
1218
|
+
function multiPropKeyed() {
|
|
1219
|
+
return overlayItem({
|
|
1220
|
+
keyframes: [
|
|
1221
|
+
{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 6, value: 100 }] },
|
|
1222
|
+
{ prop: 'opacity', points: [{ t: 2, value: 0 }, { t: 8, value: 1 }] },
|
|
1223
|
+
],
|
|
1224
|
+
})
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
it('seeks to the previous keyframe across ALL props, in absolute timeline time', () => {
|
|
1228
|
+
const onSeek = vi.fn()
|
|
1229
|
+
renderInspector(multiPropKeyed(), 10, { onSeek }) // localT = 5
|
|
1230
|
+
|
|
1231
|
+
fireEvent.click(screen.getByRole('button', { name: 'Previous keyframe' }))
|
|
1232
|
+
|
|
1233
|
+
// Largest t < 5 across both tracks is 2, and the seek is item.start + t.
|
|
1234
|
+
expect(onSeek).toHaveBeenCalledTimes(1)
|
|
1235
|
+
expect(onSeek).toHaveBeenCalledWith(7)
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
it('seeks to the next keyframe across ALL props', () => {
|
|
1239
|
+
const onSeek = vi.fn()
|
|
1240
|
+
renderInspector(multiPropKeyed(), 10, { onSeek }) // localT = 5
|
|
1241
|
+
|
|
1242
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next keyframe' }))
|
|
1243
|
+
|
|
1244
|
+
// Smallest t > 5 across both tracks is 6.
|
|
1245
|
+
expect(onSeek).toHaveBeenCalledWith(11)
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
it('renders the arrows disabled when the host supplies no onSeek', () => {
|
|
1249
|
+
renderInspector(multiPropKeyed(), 10)
|
|
1250
|
+
|
|
1251
|
+
expect(screen.getByRole('button', { name: 'Previous keyframe' })).toBeDisabled()
|
|
1252
|
+
expect(screen.getByRole('button', { name: 'Next keyframe' })).toBeDisabled()
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
it('disables an arrow when there is no keyframe in that direction', () => {
|
|
1256
|
+
const onSeek = vi.fn()
|
|
1257
|
+
renderInspector(multiPropKeyed(), 5, { onSeek }) // localT = 0, nothing before it
|
|
1258
|
+
|
|
1259
|
+
expect(screen.getByRole('button', { name: 'Previous keyframe' })).toBeDisabled()
|
|
1260
|
+
expect(screen.getByRole('button', { name: 'Next keyframe' })).toBeEnabled()
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
it('gives each row its own arrows, scoped to that prop', () => {
|
|
1264
|
+
const onSeek = vi.fn()
|
|
1265
|
+
renderInspector(multiPropKeyed(), 10, { onSeek }) // localT = 5
|
|
1266
|
+
|
|
1267
|
+
// The Position pair covers offsetX (points 0, 6); largest t < 5 is 0, and
|
|
1268
|
+
// the seek is item.start + t = 5. opacity's 2 and 8 are not its business.
|
|
1269
|
+
fireEvent.click(screen.getByRole('button', { name: 'Previous Position keyframe' }))
|
|
1270
|
+
expect(onSeek).toHaveBeenCalledWith(5)
|
|
1271
|
+
|
|
1272
|
+
// Scale has no track at all.
|
|
1273
|
+
expect(screen.getByRole('button', { name: 'Previous Scale keyframe' })).toBeDisabled()
|
|
1274
|
+
expect(screen.getByRole('button', { name: 'Next Scale keyframe' })).toBeDisabled()
|
|
1275
|
+
})
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
describe('OverlayInspector — keyframe all transform properties', () => {
|
|
1279
|
+
it('is not pressed unless EVERY prop has a point at the playhead', () => {
|
|
1280
|
+
const item = overlayItem({ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 10 }] }] })
|
|
1281
|
+
renderInspector(item, 5) // localT = 0
|
|
1282
|
+
|
|
1283
|
+
expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'false')
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
it('is pressed when all five have a point at the playhead', () => {
|
|
1287
|
+
renderInspector(allKeyedItem(), 5) // localT = 0
|
|
1288
|
+
|
|
1289
|
+
expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'true')
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
it('is not pressed when the playhead sits between the points', () => {
|
|
1293
|
+
renderInspector(allKeyedItem(), 8) // localT = 3, no track has a point there
|
|
1294
|
+
|
|
1295
|
+
expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'false')
|
|
1296
|
+
})
|
|
1297
|
+
|
|
1298
|
+
it('adds a point at the playhead to all five without moving anything', () => {
|
|
1299
|
+
const item = overlayItem({
|
|
1300
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
|
|
1301
|
+
})
|
|
1302
|
+
const { onChange } = renderInspector(item, 8) // localT = 3
|
|
1303
|
+
|
|
1304
|
+
fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
|
|
1305
|
+
|
|
1306
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
1307
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
1308
|
+
for (const prop of ALL_PROPS) {
|
|
1309
|
+
expect(hasKeyframes(next, prop)).toBe(true)
|
|
1310
|
+
expect(trackFor(next, prop)!.points.some(p => p.t === 3)).toBe(true)
|
|
1311
|
+
// Nothing on screen moves: every sampled value is exactly what it was.
|
|
1312
|
+
expect(valueAt(next, prop, 3)).toBe(valueAt(item, prop, 3))
|
|
1313
|
+
}
|
|
1314
|
+
// The prop that was ALREADY animated keeps its curve — enableKeyframing's
|
|
1315
|
+
// no-op contract is what protects it.
|
|
1316
|
+
expect(valueAt(next, 'offsetX', 0)).toBe(0)
|
|
1317
|
+
expect(valueAt(next, 'offsetX', 10)).toBe(100)
|
|
1318
|
+
})
|
|
1319
|
+
|
|
1320
|
+
it('removes just the point at the playhead from multi-point tracks', () => {
|
|
1321
|
+
const { onChange } = renderInspector(allKeyedItem(), 5) // localT = 0, all pressed
|
|
1322
|
+
|
|
1323
|
+
fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
|
|
1324
|
+
|
|
1325
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
1326
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
1327
|
+
for (const prop of ALL_PROPS) {
|
|
1328
|
+
expect(hasKeyframes(next, prop)).toBe(true) // still animated
|
|
1329
|
+
expect(trackFor(next, prop)!.points.some(p => p.t === 0)).toBe(false)
|
|
1330
|
+
}
|
|
1331
|
+
expect(trackFor(next, 'offsetX')!.points).toEqual([{ t: 10, value: 100 }])
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
it('writes the static scalar for a prop whose ONLY point is at the playhead', () => {
|
|
1335
|
+
const item = overlayItem({
|
|
1336
|
+
// Stale scalars: whatever was left behind when keyframing was switched
|
|
1337
|
+
// on. `removeKeyframe` would drop the track and let these snap back.
|
|
1338
|
+
offsetX: 999,
|
|
1339
|
+
offsetY: 999,
|
|
1340
|
+
scale: 999,
|
|
1341
|
+
rotation: 999,
|
|
1342
|
+
opacity: 999,
|
|
1343
|
+
keyframes: [
|
|
1344
|
+
{ prop: 'offsetX', points: [{ t: 0, value: 3 }] },
|
|
1345
|
+
{ prop: 'offsetY', points: [{ t: 0, value: 4 }] },
|
|
1346
|
+
{ prop: 'scale', points: [{ t: 0, value: 0.5 }] },
|
|
1347
|
+
{ prop: 'rotation', points: [{ t: 0, value: 30 }] },
|
|
1348
|
+
{ prop: 'opacity', points: [{ t: 0, value: 0.25 }] },
|
|
1349
|
+
],
|
|
1350
|
+
})
|
|
1351
|
+
const { onChange } = renderInspector(item, 5) // localT = 0, all pressed
|
|
1352
|
+
|
|
1353
|
+
fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
|
|
1354
|
+
|
|
1355
|
+
const next = onChange.mock.calls[0][0] as VisualItem
|
|
1356
|
+
expect(next.keyframes).toBeUndefined()
|
|
1357
|
+
expect(next.offsetX).toBe(3)
|
|
1358
|
+
expect(next.offsetY).toBe(4)
|
|
1359
|
+
expect(next.scale).toBe(0.5)
|
|
1360
|
+
expect(next.rotation).toBe(30)
|
|
1361
|
+
expect(next.opacity).toBe(0.25)
|
|
1362
|
+
// The overlay does not move: every value survived the round trip.
|
|
1363
|
+
for (const prop of ALL_PROPS) {
|
|
1364
|
+
expect(valueAt(next, prop, 0)).toBe(valueAt(item, prop, 0))
|
|
1365
|
+
}
|
|
1366
|
+
})
|
|
1367
|
+
})
|