@hyperframes/studio 0.7.54 → 0.7.56
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/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
clampTimelineZoomPercent,
|
|
4
|
+
computePinnedZoomPercent,
|
|
4
5
|
getNextTimelineZoomPercent,
|
|
5
6
|
getPinchTimelineZoomPercent,
|
|
6
7
|
getTimelinePixelsPerSecond,
|
|
7
8
|
getTimelineZoomPercent,
|
|
8
9
|
MAX_TIMELINE_ZOOM_PERCENT,
|
|
9
10
|
MIN_TIMELINE_ZOOM_PERCENT,
|
|
11
|
+
timelineZoomPercentToSlider,
|
|
12
|
+
timelineSliderToZoomPercent,
|
|
10
13
|
} from "./timelineZoom";
|
|
11
14
|
|
|
12
15
|
describe("clampTimelineZoomPercent", () => {
|
|
@@ -81,3 +84,67 @@ describe("getPinchTimelineZoomPercent", () => {
|
|
|
81
84
|
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
82
85
|
});
|
|
83
86
|
});
|
|
87
|
+
|
|
88
|
+
describe("timelineZoomPercentToSlider", () => {
|
|
89
|
+
it("maps min zoom to slider position 0", () => {
|
|
90
|
+
expect(timelineZoomPercentToSlider(MIN_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(0, 5);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("maps max zoom to slider position 100", () => {
|
|
94
|
+
expect(timelineZoomPercentToSlider(MAX_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(100, 5);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("maps 100% to the log midpoint between 10 and 2000", () => {
|
|
98
|
+
const expected = ((Math.log(100) - Math.log(10)) / (Math.log(2000) - Math.log(10))) * 100;
|
|
99
|
+
expect(timelineZoomPercentToSlider(100)).toBeCloseTo(expected, 3);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("timelineSliderToZoomPercent", () => {
|
|
104
|
+
it("maps slider 0 to min zoom", () => {
|
|
105
|
+
expect(timelineSliderToZoomPercent(0)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("maps slider 100 to max zoom", () => {
|
|
109
|
+
expect(timelineSliderToZoomPercent(100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("computePinnedZoomPercent", () => {
|
|
114
|
+
it("returns 100 when current pps equals the fit pps (a no-op pin at the current fit)", () => {
|
|
115
|
+
expect(computePinnedZoomPercent(42, 42)).toBe(100);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("reproduces the current pps: percent × fitPps / 100 === currentPps", () => {
|
|
119
|
+
const fitPps = 20;
|
|
120
|
+
const currentPps = 50; // user zoomed in 2.5×
|
|
121
|
+
const percent = computePinnedZoomPercent(currentPps, fitPps);
|
|
122
|
+
expect(percent).toBe(250);
|
|
123
|
+
// Round-trips through getTimelinePixelsPerSecond back to the on-screen pps.
|
|
124
|
+
expect(getTimelinePixelsPerSecond(fitPps, "manual", percent)).toBeCloseTo(currentPps, 5);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("clamps a pin that would exceed the manual-zoom bounds", () => {
|
|
128
|
+
// currentPps 10000 / fitPps 1 = 1_000_000% → clamped to MAX.
|
|
129
|
+
expect(computePinnedZoomPercent(10000, 1)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
130
|
+
// Tiny ratio → clamped up to MIN.
|
|
131
|
+
expect(computePinnedZoomPercent(0.001, 1000)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("falls back to 100 for unusable inputs (a safe no-op pin)", () => {
|
|
135
|
+
expect(computePinnedZoomPercent(Number.NaN, 20)).toBe(100);
|
|
136
|
+
expect(computePinnedZoomPercent(50, 0)).toBe(100);
|
|
137
|
+
expect(computePinnedZoomPercent(-5, 20)).toBe(100);
|
|
138
|
+
expect(computePinnedZoomPercent(50, Number.POSITIVE_INFINITY)).toBe(100);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("timelineZoomPercentToSlider / timelineSliderToZoomPercent round-trip", () => {
|
|
143
|
+
for (const percent of [10, 100, 500, 2000]) {
|
|
144
|
+
it(`round-trips ${percent}% within ±1%`, () => {
|
|
145
|
+
const slider = timelineZoomPercentToSlider(percent);
|
|
146
|
+
const back = timelineSliderToZoomPercent(slider);
|
|
147
|
+
expect(Math.abs(back - percent) / percent).toBeLessThan(0.01);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
@@ -18,6 +18,34 @@ export function getTimelineZoomPercent(zoomMode: ZoomMode, manualZoomPercent: nu
|
|
|
18
18
|
return zoomMode === "fit" ? 100 : clampTimelineZoomPercent(manualZoomPercent);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The manual-zoom percent that, applied to `fitPixelsPerSecond`, reproduces the
|
|
23
|
+
* CURRENT on-screen pixels-per-second exactly. Used to PIN the timeline zoom on
|
|
24
|
+
* the first edit so a duration change (which recomputes fit-pps) no longer
|
|
25
|
+
* rescales every clip: we switch `zoomMode` to "manual" with this percent, so
|
|
26
|
+
* `getTimelinePixelsPerSecond` keeps returning today's pps regardless of the new
|
|
27
|
+
* fit basis.
|
|
28
|
+
*
|
|
29
|
+
* Since `pps = fitPps * (percent / 100)` in manual mode, and while fitting
|
|
30
|
+
* `pps === fitPps`, the pinned percent is `currentPps / fitPps * 100`. Clamped to
|
|
31
|
+
* the manual-zoom range so the pin can't land outside the slider's bounds; falls
|
|
32
|
+
* back to 100 (a no-op pin at the current fit) when either input is unusable.
|
|
33
|
+
*/
|
|
34
|
+
export function computePinnedZoomPercent(
|
|
35
|
+
currentPixelsPerSecond: number,
|
|
36
|
+
fitPixelsPerSecond: number,
|
|
37
|
+
): number {
|
|
38
|
+
if (
|
|
39
|
+
!Number.isFinite(currentPixelsPerSecond) ||
|
|
40
|
+
currentPixelsPerSecond <= 0 ||
|
|
41
|
+
!Number.isFinite(fitPixelsPerSecond) ||
|
|
42
|
+
fitPixelsPerSecond <= 0
|
|
43
|
+
) {
|
|
44
|
+
return 100;
|
|
45
|
+
}
|
|
46
|
+
return clampTimelineZoomPercent((currentPixelsPerSecond / fitPixelsPerSecond) * 100);
|
|
47
|
+
}
|
|
48
|
+
|
|
21
49
|
export function getTimelinePixelsPerSecond(
|
|
22
50
|
fitPixelsPerSecond: number,
|
|
23
51
|
zoomMode: ZoomMode,
|
|
@@ -47,3 +75,26 @@ export function getPinchTimelineZoomPercent(
|
|
|
47
75
|
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
|
|
48
76
|
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
|
|
49
77
|
}
|
|
78
|
+
|
|
79
|
+
const LOG_MIN = Math.log(MIN_TIMELINE_ZOOM_PERCENT);
|
|
80
|
+
const LOG_MAX = Math.log(MAX_TIMELINE_ZOOM_PERCENT);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Maps a zoom percent (10–2000) to a slider position (0–100) using a log scale.
|
|
84
|
+
* Log scale is used because the range spans 200×; linear would compress the
|
|
85
|
+
* low end (10–100%) into a tiny sliver of the slider.
|
|
86
|
+
*/
|
|
87
|
+
export function timelineZoomPercentToSlider(percent: number): number {
|
|
88
|
+
const clamped = clampTimelineZoomPercent(percent);
|
|
89
|
+
return ((Math.log(clamped) - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 100;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Maps a slider position (0–100) to a zoom percent (10–2000) using a log scale.
|
|
94
|
+
* Inverse of `timelineZoomPercentToSlider`.
|
|
95
|
+
*/
|
|
96
|
+
export function timelineSliderToZoomPercent(slider: number): number {
|
|
97
|
+
const clampedSlider = Math.max(0, Math.min(100, slider));
|
|
98
|
+
const logValue = LOG_MIN + (clampedSlider / 100) * (LOG_MAX - LOG_MIN);
|
|
99
|
+
return clampTimelineZoomPercent(Math.exp(logValue));
|
|
100
|
+
}
|
|
@@ -6,7 +6,12 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
|
6
6
|
// the rest of the callback bag still comes from TimelineEditContext.
|
|
7
7
|
export type TimelineEditOverrides = Pick<
|
|
8
8
|
TimelineEditCallbacks,
|
|
9
|
-
|
|
9
|
+
| "onMoveElement"
|
|
10
|
+
| "onMoveElements"
|
|
11
|
+
| "onResizeElement"
|
|
12
|
+
| "onResizeElements"
|
|
13
|
+
| "onBlockedEditAttempt"
|
|
14
|
+
| "onSplitElement"
|
|
10
15
|
>;
|
|
11
16
|
|
|
12
17
|
// Merge any prop overrides over the context callbacks. Used so NLELayout can
|
|
@@ -16,15 +21,32 @@ export function useResolvedTimelineEditCallbacks(
|
|
|
16
21
|
overrides: TimelineEditOverrides,
|
|
17
22
|
): TimelineEditCallbacks {
|
|
18
23
|
const ctx = useTimelineEditContextOptional();
|
|
19
|
-
const {
|
|
24
|
+
const {
|
|
25
|
+
onMoveElement,
|
|
26
|
+
onMoveElements,
|
|
27
|
+
onResizeElement,
|
|
28
|
+
onResizeElements,
|
|
29
|
+
onBlockedEditAttempt,
|
|
30
|
+
onSplitElement,
|
|
31
|
+
} = overrides;
|
|
20
32
|
return useMemo(
|
|
21
33
|
() => ({
|
|
22
34
|
...ctx,
|
|
23
35
|
onMoveElement: onMoveElement ?? ctx.onMoveElement,
|
|
36
|
+
onMoveElements: onMoveElements ?? ctx.onMoveElements,
|
|
24
37
|
onResizeElement: onResizeElement ?? ctx.onResizeElement,
|
|
38
|
+
onResizeElements: onResizeElements ?? ctx.onResizeElements,
|
|
25
39
|
onBlockedEditAttempt: onBlockedEditAttempt ?? ctx.onBlockedEditAttempt,
|
|
26
40
|
onSplitElement: onSplitElement ?? ctx.onSplitElement,
|
|
27
41
|
}),
|
|
28
|
-
[
|
|
42
|
+
[
|
|
43
|
+
ctx,
|
|
44
|
+
onMoveElement,
|
|
45
|
+
onMoveElements,
|
|
46
|
+
onResizeElement,
|
|
47
|
+
onResizeElements,
|
|
48
|
+
onBlockedEditAttempt,
|
|
49
|
+
onSplitElement,
|
|
50
|
+
],
|
|
29
51
|
);
|
|
30
52
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
6
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
8
|
+
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
9
|
+
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
10
|
+
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
|
|
13
|
+
function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
key: id,
|
|
17
|
+
tag: "video",
|
|
18
|
+
start: 0,
|
|
19
|
+
duration: 2,
|
|
20
|
+
track: 0,
|
|
21
|
+
domId: id,
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
usePlayerStore.getState().reset();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function renderResizeHarness(
|
|
32
|
+
elements: TimelineElement[],
|
|
33
|
+
selected: string[],
|
|
34
|
+
options: { wireGroupResize?: boolean } = {},
|
|
35
|
+
) {
|
|
36
|
+
usePlayerStore.getState().setElements(elements);
|
|
37
|
+
usePlayerStore.setState({ timelineSnapEnabled: false });
|
|
38
|
+
usePlayerStore.getState().setSelectedElementIds(new Set(selected));
|
|
39
|
+
|
|
40
|
+
const scroll = document.createElement("div");
|
|
41
|
+
document.body.append(scroll);
|
|
42
|
+
const onResizeElement = vi.fn();
|
|
43
|
+
const onResizeElements = vi.fn().mockResolvedValue(undefined);
|
|
44
|
+
let setResizingClip: ((s: ResizingClipState | null) => void) | null = null;
|
|
45
|
+
|
|
46
|
+
function Harness() {
|
|
47
|
+
const hook = useTimelineClipDrag({
|
|
48
|
+
scrollRef: { current: scroll },
|
|
49
|
+
ppsRef: { current: 100 },
|
|
50
|
+
durationRef: { current: 100 },
|
|
51
|
+
trackOrderRef: { current: [0, 1] },
|
|
52
|
+
onResizeElement,
|
|
53
|
+
onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
|
|
54
|
+
setShowPopover: vi.fn(),
|
|
55
|
+
setRangeSelectionRef: { current: vi.fn() },
|
|
56
|
+
});
|
|
57
|
+
setResizingClip = hook.setResizingClip;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const root = mountReactHarness(<Harness />);
|
|
62
|
+
const apply = setResizingClip!;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
onResizeElement,
|
|
66
|
+
onResizeElements,
|
|
67
|
+
storeById(id: string) {
|
|
68
|
+
return usePlayerStore.getState().elements.find((e) => e.id === id)!;
|
|
69
|
+
},
|
|
70
|
+
startResize(element: TimelineElement, edge: "start" | "end") {
|
|
71
|
+
act(() => {
|
|
72
|
+
apply({
|
|
73
|
+
element,
|
|
74
|
+
edge,
|
|
75
|
+
originClientX: 0,
|
|
76
|
+
previewStart: element.start,
|
|
77
|
+
previewDuration: element.duration,
|
|
78
|
+
previewPlaybackStart: element.playbackStart,
|
|
79
|
+
started: false,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
movePointer(clientX: number) {
|
|
84
|
+
act(() => {
|
|
85
|
+
window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY: 0 }));
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
async dropPointer() {
|
|
89
|
+
await act(async () => {
|
|
90
|
+
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
pressEscape() {
|
|
94
|
+
act(() => {
|
|
95
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
unmount() {
|
|
99
|
+
act(() => root.unmount());
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Two clips a(0,2) + b(5,3) selected as a group, with a's END edge grabbed and
|
|
105
|
+
// dragged +0.5s (50px @ 100pps). `bOver` customizes b (e.g. locking it).
|
|
106
|
+
function startGroupResize(bOver: Partial<TimelineElement> = {}) {
|
|
107
|
+
const a = el("a", { start: 0, duration: 2 });
|
|
108
|
+
const b = el("b", { start: 5, duration: 3, ...bOver });
|
|
109
|
+
const h = renderResizeHarness([a, b], ["a", "b"]);
|
|
110
|
+
h.startResize(a, "end");
|
|
111
|
+
h.movePointer(50);
|
|
112
|
+
return { a, b, h };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Drop the pointer and assert exactly one resize persisted: the grabbed clip `a`
|
|
116
|
+
// grown to 2.5s (the +0.5 gesture), with no other member patched.
|
|
117
|
+
async function expectSingleResizeToA(
|
|
118
|
+
h: ReturnType<typeof renderResizeHarness>,
|
|
119
|
+
a: TimelineElement,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
await h.dropPointer();
|
|
122
|
+
expect(h.onResizeElement).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.5 }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => {
|
|
127
|
+
it("resizes only the grabbed clip and persists once", async () => {
|
|
128
|
+
const a = el("a", { start: 0, duration: 2 });
|
|
129
|
+
const b = el("b", { start: 5, duration: 2 });
|
|
130
|
+
const h = renderResizeHarness([a, b], []); // no multi-selection
|
|
131
|
+
|
|
132
|
+
h.startResize(a, "end");
|
|
133
|
+
h.movePointer(50); // +0.5s at 100 pps
|
|
134
|
+
await expectSingleResizeToA(h, a);
|
|
135
|
+
expect(h.storeById("a").duration).toBe(2.5);
|
|
136
|
+
expect(h.storeById("b").duration).toBe(2); // untouched
|
|
137
|
+
h.unmount();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does not let an older rejected resize roll back a newer optimistic gesture", async () => {
|
|
141
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
142
|
+
const a = el("a", { start: 0, duration: 2 });
|
|
143
|
+
const h = renderResizeHarness([a], []);
|
|
144
|
+
let rejectFirst!: (error: Error) => void;
|
|
145
|
+
h.onResizeElement
|
|
146
|
+
.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => (rejectFirst = reject)))
|
|
147
|
+
.mockResolvedValueOnce(undefined);
|
|
148
|
+
|
|
149
|
+
h.startResize(a, "end");
|
|
150
|
+
h.movePointer(50);
|
|
151
|
+
await h.dropPointer();
|
|
152
|
+
h.startResize(a, "end");
|
|
153
|
+
h.movePointer(100);
|
|
154
|
+
await h.dropPointer();
|
|
155
|
+
rejectFirst(new Error("older resize failed"));
|
|
156
|
+
await act(async () => Promise.resolve());
|
|
157
|
+
|
|
158
|
+
expect(h.storeById("a").duration).toBe(3);
|
|
159
|
+
errorSpy.mockRestore();
|
|
160
|
+
h.unmount();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
|
|
165
|
+
it("previews the non-grabbed member through the store, grabbed stays out until commit", () => {
|
|
166
|
+
const { h } = startGroupResize(); // grabbed asks +0.5
|
|
167
|
+
|
|
168
|
+
// Non-grabbed member is previewed in the store; the grabbed clip renders from
|
|
169
|
+
// resizingClip state, so its store value is still the original until commit.
|
|
170
|
+
expect(h.storeById("b").duration).toBe(3.5);
|
|
171
|
+
expect(h.storeById("a").duration).toBe(2);
|
|
172
|
+
h.unmount();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("commits the whole group — persists every member by the shared delta", async () => {
|
|
176
|
+
const { a, b, h } = startGroupResize();
|
|
177
|
+
await h.dropPointer();
|
|
178
|
+
|
|
179
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
180
|
+
expect(h.onResizeElements).toHaveBeenCalledTimes(1);
|
|
181
|
+
expect(h.onResizeElements).toHaveBeenCalledWith(
|
|
182
|
+
[
|
|
183
|
+
expect.objectContaining({ element: a, duration: 2.5 }),
|
|
184
|
+
expect.objectContaining({ element: b, duration: 3.5 }),
|
|
185
|
+
],
|
|
186
|
+
{ coalesceKey: expect.stringMatching(/^clip-group-resize:/) },
|
|
187
|
+
);
|
|
188
|
+
expect(h.storeById("a").duration).toBe(2.5);
|
|
189
|
+
expect(h.storeById("b").duration).toBe(3.5);
|
|
190
|
+
h.unmount();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("rolls the whole group back when the atomic resize batch rejects", async () => {
|
|
194
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
195
|
+
const { h } = startGroupResize();
|
|
196
|
+
h.onResizeElements.mockRejectedValueOnce(new Error("batch failed"));
|
|
197
|
+
await h.dropPointer();
|
|
198
|
+
await act(async () => Promise.resolve());
|
|
199
|
+
expect(h.onResizeElements).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
201
|
+
expect(h.storeById("a").duration).toBe(2);
|
|
202
|
+
expect(h.storeById("b").duration).toBe(3);
|
|
203
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
204
|
+
errorSpy.mockRestore();
|
|
205
|
+
h.unmount();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("rolls the whole group back when no atomic resize callback is wired", async () => {
|
|
209
|
+
const a = el("a", { start: 0, duration: 2 });
|
|
210
|
+
const b = el("b", { start: 5, duration: 3 });
|
|
211
|
+
const h = renderResizeHarness([a, b], ["a", "b"], {
|
|
212
|
+
wireGroupResize: false,
|
|
213
|
+
});
|
|
214
|
+
h.startResize(a, "end");
|
|
215
|
+
h.movePointer(50);
|
|
216
|
+
expect(h.storeById("b").duration).toBe(3.5);
|
|
217
|
+
|
|
218
|
+
await h.dropPointer();
|
|
219
|
+
expect(h.storeById("a").duration).toBe(2);
|
|
220
|
+
expect(h.storeById("b").duration).toBe(3);
|
|
221
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
222
|
+
h.unmount();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("degrades to single-clip when a selected member is locked — locked clip untouched", async () => {
|
|
226
|
+
const { a, h } = startGroupResize({ timelineLocked: true });
|
|
227
|
+
await expectSingleResizeToA(h, a);
|
|
228
|
+
expect(h.storeById("b").duration).toBe(3); // locked member never patched
|
|
229
|
+
h.unmount();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("Escape rolls back the previewed non-grabbed member and persists nothing", () => {
|
|
233
|
+
const { h } = startGroupResize();
|
|
234
|
+
expect(h.storeById("b").duration).toBe(3.5); // previewed
|
|
235
|
+
|
|
236
|
+
h.pressEscape();
|
|
237
|
+
expect(h.storeById("b").duration).toBe(3); // restored
|
|
238
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
239
|
+
h.unmount();
|
|
240
|
+
});
|
|
241
|
+
});
|