@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
|
@@ -3,26 +3,26 @@ import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatT
|
|
|
3
3
|
|
|
4
4
|
describe("formatTime", () => {
|
|
5
5
|
it("formats zero seconds", () => {
|
|
6
|
-
expect(formatTime(0)).toBe("
|
|
6
|
+
expect(formatTime(0)).toBe("00:00");
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
// fallow-ignore-next-line code-duplication
|
|
10
10
|
it("formats seconds less than a minute", () => {
|
|
11
|
-
expect(formatTime(5)).toBe("
|
|
12
|
-
expect(formatTime(30)).toBe("
|
|
13
|
-
expect(formatTime(59)).toBe("
|
|
11
|
+
expect(formatTime(5)).toBe("00:05");
|
|
12
|
+
expect(formatTime(30)).toBe("00:30");
|
|
13
|
+
expect(formatTime(59)).toBe("00:59");
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
it("formats exact minutes", () => {
|
|
17
|
-
expect(formatTime(60)).toBe("
|
|
18
|
-
expect(formatTime(120)).toBe("
|
|
17
|
+
expect(formatTime(60)).toBe("01:00");
|
|
18
|
+
expect(formatTime(120)).toBe("02:00");
|
|
19
19
|
expect(formatTime(600)).toBe("10:00");
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
it("formats minutes and seconds", () => {
|
|
23
|
-
expect(formatTime(65)).toBe("
|
|
24
|
-
expect(formatTime(90)).toBe("
|
|
25
|
-
expect(formatTime(125)).toBe("
|
|
23
|
+
expect(formatTime(65)).toBe("01:05");
|
|
24
|
+
expect(formatTime(90)).toBe("01:30");
|
|
25
|
+
expect(formatTime(125)).toBe("02:05");
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
it("formats large values (over an hour)", () => {
|
|
@@ -32,28 +32,28 @@ describe("formatTime", () => {
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
it("floors fractional seconds", () => {
|
|
35
|
-
expect(formatTime(0.9)).toBe("
|
|
36
|
-
expect(formatTime(1.5)).toBe("
|
|
37
|
-
expect(formatTime(59.99)).toBe("
|
|
38
|
-
expect(formatTime(60.5)).toBe("
|
|
35
|
+
expect(formatTime(0.9)).toBe("00:00");
|
|
36
|
+
expect(formatTime(1.5)).toBe("00:01");
|
|
37
|
+
expect(formatTime(59.99)).toBe("00:59");
|
|
38
|
+
expect(formatTime(60.5)).toBe("01:00");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it("pads single-digit seconds with leading
|
|
42
|
-
expect(formatTime(1)).toBe("
|
|
43
|
-
expect(formatTime(61)).toBe("
|
|
41
|
+
it("pads single-digit minutes and seconds with leading zeros", () => {
|
|
42
|
+
expect(formatTime(1)).toBe("00:01");
|
|
43
|
+
expect(formatTime(61)).toBe("01:01");
|
|
44
44
|
expect(formatTime(609)).toBe("10:09");
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
it("guards against negative values", () => {
|
|
48
|
-
expect(formatTime(-1)).toBe("
|
|
48
|
+
expect(formatTime(-1)).toBe("00:00");
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
it("guards against NaN", () => {
|
|
52
|
-
expect(formatTime(NaN)).toBe("
|
|
52
|
+
expect(formatTime(NaN)).toBe("00:00");
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
it("guards against Infinity", () => {
|
|
56
|
-
expect(formatTime(Infinity)).toBe("
|
|
56
|
+
expect(formatTime(Infinity)).toBe("00:00");
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
package/src/player/lib/time.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
export const STUDIO_PREVIEW_FPS = 30;
|
|
2
2
|
|
|
3
3
|
export function formatTime(time: number): string {
|
|
4
|
-
if (!Number.isFinite(time) || time < 0) return "
|
|
4
|
+
if (!Number.isFinite(time) || time < 0) return "00:00";
|
|
5
5
|
const mins = Math.floor(time / 60);
|
|
6
6
|
const secs = Math.floor(time % 60);
|
|
7
|
-
|
|
7
|
+
// Zero-pad minutes as well as seconds so every readout is a stable-width
|
|
8
|
+
// MM:SS (e.g. "00:44", not "0:44"). Minutes past 99 keep their extra digits
|
|
9
|
+
// ("120:00"); an hours-style H:MM:SS grouping is applied by callers that need
|
|
10
|
+
// it (see formatTimelineTickLabel), never here.
|
|
11
|
+
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
@@ -4,8 +4,13 @@ import {
|
|
|
4
4
|
createTimelineElementFromManifestClip,
|
|
5
5
|
parseTimelineFromDOM,
|
|
6
6
|
createImplicitTimelineLayersFromDOM,
|
|
7
|
-
|
|
7
|
+
mergeTimelineElementsPreservingDowngrades,
|
|
8
8
|
} from "./timelineDOM";
|
|
9
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
10
|
+
|
|
11
|
+
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
|
12
|
+
return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
|
|
13
|
+
}
|
|
9
14
|
|
|
10
15
|
function makeDoc(html: string): Document {
|
|
11
16
|
const d = document.implementation.createHTMLDocument();
|
|
@@ -13,29 +18,6 @@ function makeDoc(html: string): Document {
|
|
|
13
18
|
return d;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
function makeLiveDoc(html: string): Document {
|
|
17
|
-
document.head.innerHTML = "";
|
|
18
|
-
document.body.innerHTML = html;
|
|
19
|
-
return document;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function mockComputedZIndex(doc: Document, zIndexById: ReadonlyMap<string, string>): void {
|
|
23
|
-
const win = doc.defaultView;
|
|
24
|
-
if (!win) throw new Error("Expected document window");
|
|
25
|
-
const original = win.getComputedStyle.bind(win);
|
|
26
|
-
Object.defineProperty(win, "getComputedStyle", {
|
|
27
|
-
configurable: true,
|
|
28
|
-
value: (element: Element, pseudoElt?: string | null) => {
|
|
29
|
-
const style = original(element, pseudoElt);
|
|
30
|
-
const zIndex = zIndexById.get(element.id);
|
|
31
|
-
if (zIndex != null) {
|
|
32
|
-
Object.defineProperty(style, "zIndex", { configurable: true, value: zIndex });
|
|
33
|
-
}
|
|
34
|
-
return style;
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
21
|
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
40
22
|
it("harvests hfId from a data-start element that has data-hf-id", () => {
|
|
41
23
|
const doc = makeDoc(`
|
|
@@ -126,58 +108,6 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
|
126
108
|
|
|
127
109
|
expect(element.hidden).toBe(true);
|
|
128
110
|
});
|
|
129
|
-
|
|
130
|
-
it("captures the effective z-index from the live element, not the runtime inline-only value", () => {
|
|
131
|
-
// The runtime reports inline-only z-index (0 for CSS-rule authored z-index),
|
|
132
|
-
// which must NOT override the live element's effective z-index — otherwise
|
|
133
|
-
// the timeline collapses every CSS-styled clip to a z=0 tie and mis-orders.
|
|
134
|
-
const doc = makeDoc(`
|
|
135
|
-
<div data-composition-id="root">
|
|
136
|
-
<div id="hero" class="clip" data-start="0" data-duration="5" style="z-index: 30"></div>
|
|
137
|
-
</div>
|
|
138
|
-
`);
|
|
139
|
-
const hostEl = doc.getElementById("hero");
|
|
140
|
-
|
|
141
|
-
const element = createTimelineElementFromManifestClip({
|
|
142
|
-
clip: {
|
|
143
|
-
id: "hero",
|
|
144
|
-
label: "Hero",
|
|
145
|
-
kind: "element",
|
|
146
|
-
tagName: "div",
|
|
147
|
-
start: 0,
|
|
148
|
-
duration: 5,
|
|
149
|
-
track: 0,
|
|
150
|
-
zIndex: 0,
|
|
151
|
-
compositionId: null,
|
|
152
|
-
parentCompositionId: null,
|
|
153
|
-
compositionSrc: null,
|
|
154
|
-
assetUrl: null,
|
|
155
|
-
},
|
|
156
|
-
fallbackIndex: 0,
|
|
157
|
-
doc,
|
|
158
|
-
hostEl,
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
expect(element.zIndex).toBe(30);
|
|
162
|
-
expect(element.hasExplicitZIndex).toBe(true);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("marks parsed inline, CSS-rule, and auto z-index authorship accurately", () => {
|
|
166
|
-
const doc = makeLiveDoc(`
|
|
167
|
-
<div data-composition-id="root">
|
|
168
|
-
<div id="inline" class="clip" data-start="0" data-duration="2" style="z-index: 3"></div>
|
|
169
|
-
<div id="rule" class="clip" data-start="0" data-duration="2"></div>
|
|
170
|
-
<div id="auto" class="clip" data-start="0" data-duration="2"></div>
|
|
171
|
-
</div>
|
|
172
|
-
`);
|
|
173
|
-
mockComputedZIndex(doc, new Map([["rule", "12"]]));
|
|
174
|
-
|
|
175
|
-
const elements = parseTimelineFromDOM(doc, 10);
|
|
176
|
-
|
|
177
|
-
expect(elements.find((el) => el.id === "inline")?.hasExplicitZIndex).toBe(true);
|
|
178
|
-
expect(elements.find((el) => el.id === "rule")?.hasExplicitZIndex).toBe(true);
|
|
179
|
-
expect(elements.find((el) => el.id === "auto")?.hasExplicitZIndex).toBe(false);
|
|
180
|
-
});
|
|
181
111
|
});
|
|
182
112
|
|
|
183
113
|
describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
|
@@ -211,31 +141,28 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
|
|
211
141
|
|
|
212
142
|
expect(layers).toEqual([]);
|
|
213
143
|
});
|
|
144
|
+
});
|
|
214
145
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
mockComputedZIndex(doc, new Map([["layer", "8"]]));
|
|
222
|
-
|
|
223
|
-
const layers = createImplicitTimelineLayersFromDOM(doc, 10);
|
|
224
|
-
|
|
225
|
-
expect(layers[0]?.zIndex).toBe(8);
|
|
226
|
-
expect(layers[0]?.hasExplicitZIndex).toBe(true);
|
|
146
|
+
describe("mergeTimelineElementsPreservingDowngrades — genuine removal vs transient downgrade", () => {
|
|
147
|
+
it("drops a removed TOP-LEVEL element (undo of a split) instead of ghosting it", () => {
|
|
148
|
+
const current = [el("a"), el("a-split")]; // post-split store: original + clone
|
|
149
|
+
const next = [el("a")]; // fresh scan of the reverted file: clone gone
|
|
150
|
+
const merged = mergeTimelineElementsPreservingDowngrades(current, next, 30, 30);
|
|
151
|
+
expect(merged.map((e) => e.id)).toEqual(["a"]);
|
|
227
152
|
});
|
|
228
|
-
});
|
|
229
153
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
iframeSrc: "/preview/comp/index.html",
|
|
237
|
-
});
|
|
154
|
+
it("still preserves an enriched sub-composition child a bare re-scan drops", () => {
|
|
155
|
+
const current = [el("a"), el("sub-child", { compositionSrc: "sub.html" })];
|
|
156
|
+
const next = [el("a")]; // bare DOM scan misses the enriched sub-comp child
|
|
157
|
+
const merged = mergeTimelineElementsPreservingDowngrades(current, next, 30, 30);
|
|
158
|
+
expect(merged.map((e) => e.id).sort()).toEqual(["a", "sub-child"]);
|
|
159
|
+
});
|
|
238
160
|
|
|
239
|
-
|
|
161
|
+
it("trusts the fresh scan fully when it is not shorter", () => {
|
|
162
|
+
const current = [el("a"), el("b", { compositionSrc: "sub.html" })];
|
|
163
|
+
const next = [el("a"), el("c")];
|
|
164
|
+
expect(
|
|
165
|
+
mergeTimelineElementsPreservingDowngrades(current, next, 30, 30).map((e) => e.id),
|
|
166
|
+
).toEqual(["a", "c"]);
|
|
240
167
|
});
|
|
241
168
|
});
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
|
|
11
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
12
|
import type { ClipManifestClip } from "./playbackTypes";
|
|
13
|
-
import { getElementZIndex, hasExplicitZIndex } from "./layerOrdering";
|
|
14
13
|
import {
|
|
15
14
|
resolveMediaElement,
|
|
16
15
|
applyMediaMetadataFromElement,
|
|
@@ -24,6 +23,7 @@ import {
|
|
|
24
23
|
buildTimelineElementIdentity,
|
|
25
24
|
getTimelineElementIdentity,
|
|
26
25
|
isTimelineIgnoredElement,
|
|
26
|
+
readTimelineElementZIndex,
|
|
27
27
|
} from "./timelineElementHelpers";
|
|
28
28
|
|
|
29
29
|
// Re-export helpers that were previously public from this module so that
|
|
@@ -66,49 +66,6 @@ function resolveClipTag(clip: ClipManifestClip): string {
|
|
|
66
66
|
return clip.tagName || clip.kind || "div";
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function resolveDomCompositionContext(
|
|
70
|
-
element: Element,
|
|
71
|
-
root: Element | null,
|
|
72
|
-
): {
|
|
73
|
-
parentCompositionId: string | null;
|
|
74
|
-
compositionAncestors: string[];
|
|
75
|
-
stackingContextId: string | null;
|
|
76
|
-
} {
|
|
77
|
-
const ancestors: string[] = [];
|
|
78
|
-
let parentCompositionId: string | null = null;
|
|
79
|
-
let cursor = element.parentElement;
|
|
80
|
-
while (cursor) {
|
|
81
|
-
const compositionId = cursor.getAttribute("data-composition-id");
|
|
82
|
-
if (compositionId) {
|
|
83
|
-
ancestors.push(compositionId);
|
|
84
|
-
if (!parentCompositionId && cursor !== root) {
|
|
85
|
-
parentCompositionId = compositionId;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
cursor = cursor.parentElement;
|
|
89
|
-
}
|
|
90
|
-
const compositionAncestors = ancestors.reverse();
|
|
91
|
-
return {
|
|
92
|
-
parentCompositionId,
|
|
93
|
-
compositionAncestors,
|
|
94
|
-
stackingContextId: parentCompositionId ?? compositionAncestors[0] ?? null,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function isHTMLElement(element: Element | null): element is HTMLElement {
|
|
99
|
-
if (!element) return false;
|
|
100
|
-
const HtmlElementCtor = element.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
|
|
101
|
-
return typeof HtmlElementCtor !== "undefined" && element instanceof HtmlElementCtor;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function getTimelineElementZIndex(element: Element | null): number | undefined {
|
|
105
|
-
return isHTMLElement(element) ? getElementZIndex(element) : undefined;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function getTimelineElementHasExplicitZIndex(element: Element | null): boolean {
|
|
109
|
-
return isHTMLElement(element) ? hasExplicitZIndex(element) : false;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
69
|
// fallow-ignore-next-line complexity
|
|
113
70
|
export function createTimelineElementFromManifestClip(params: {
|
|
114
71
|
clip: ClipManifestClip;
|
|
@@ -130,14 +87,6 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
130
87
|
let sourceFile: string | undefined;
|
|
131
88
|
|
|
132
89
|
let hfId: string | undefined;
|
|
133
|
-
const domContext = hostEl
|
|
134
|
-
? resolveDomCompositionContext(hostEl, doc?.querySelector("[data-composition-id]") ?? null)
|
|
135
|
-
: null;
|
|
136
|
-
const compositionAncestors = clip.compositionAncestors ?? domContext?.compositionAncestors;
|
|
137
|
-
const parentCompositionId = clip.parentCompositionId ?? domContext?.parentCompositionId;
|
|
138
|
-
const stackingContextId =
|
|
139
|
-
clip.stackingContextId ?? parentCompositionId ?? compositionAncestors?.[0] ?? null;
|
|
140
|
-
|
|
141
90
|
if (hostEl) {
|
|
142
91
|
domId = hostEl.id || undefined;
|
|
143
92
|
hfId = hostEl.getAttribute("data-hf-id") || undefined;
|
|
@@ -164,15 +113,6 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
164
113
|
start: clip.start,
|
|
165
114
|
duration: clip.duration,
|
|
166
115
|
track: clip.track,
|
|
167
|
-
// Prefer the effective (computed) z-index read from the live element — the
|
|
168
|
-
// same read the reorder commit uses — so CSS-rule z-index (not just inline)
|
|
169
|
-
// is captured. clip.zIndex from the runtime is inline-only (0 for CSS rules),
|
|
170
|
-
// so it can only serve as a fallback when the element isn't live.
|
|
171
|
-
zIndex: getTimelineElementZIndex(hostEl) ?? clip.zIndex ?? 0,
|
|
172
|
-
hasExplicitZIndex: getTimelineElementHasExplicitZIndex(hostEl),
|
|
173
|
-
stackingContextId,
|
|
174
|
-
parentCompositionId,
|
|
175
|
-
compositionAncestors,
|
|
176
116
|
domId,
|
|
177
117
|
hfId,
|
|
178
118
|
selector,
|
|
@@ -185,6 +125,7 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
185
125
|
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
|
|
186
126
|
const timelineRole = hostEl.getAttribute("data-timeline-role");
|
|
187
127
|
if (timelineRole) entry.timelineRole = timelineRole;
|
|
128
|
+
entry.zIndex = readTimelineElementZIndex(hostEl);
|
|
188
129
|
}
|
|
189
130
|
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
190
131
|
if (clip.kind === "composition" && clip.compositionId) {
|
|
@@ -207,8 +148,6 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
207
148
|
}
|
|
208
149
|
}
|
|
209
150
|
if (hostEl) {
|
|
210
|
-
entry.zIndex = getTimelineElementZIndex(hostEl) ?? entry.zIndex;
|
|
211
|
-
entry.hasExplicitZIndex = getTimelineElementHasExplicitZIndex(hostEl);
|
|
212
151
|
entry.domId = hostEl.id || undefined;
|
|
213
152
|
entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
|
|
214
153
|
entry.selector = getTimelineElementSelector(hostEl);
|
|
@@ -269,10 +208,10 @@ export function createImplicitTimelineLayersFromDOM(
|
|
|
269
208
|
});
|
|
270
209
|
if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
|
|
271
210
|
|
|
272
|
-
const compositionContext = resolveDomCompositionContext(child, rootComp);
|
|
273
211
|
layers.push({
|
|
274
212
|
domId: child.id || undefined,
|
|
275
213
|
hfId: child.getAttribute("data-hf-id") || undefined,
|
|
214
|
+
zIndex: readTimelineElementZIndex(child),
|
|
276
215
|
duration: rootDuration,
|
|
277
216
|
id: identity.id,
|
|
278
217
|
key: identity.key,
|
|
@@ -281,11 +220,6 @@ export function createImplicitTimelineLayersFromDOM(
|
|
|
281
220
|
selectorIndex,
|
|
282
221
|
sourceFile,
|
|
283
222
|
start: 0,
|
|
284
|
-
zIndex: getTimelineElementZIndex(child),
|
|
285
|
-
hasExplicitZIndex: getTimelineElementHasExplicitZIndex(child),
|
|
286
|
-
stackingContextId: compositionContext.stackingContextId,
|
|
287
|
-
parentCompositionId: compositionContext.parentCompositionId,
|
|
288
|
-
compositionAncestors: compositionContext.compositionAncestors,
|
|
289
223
|
tag: child.tagName.toLowerCase(),
|
|
290
224
|
timingSource: "implicit",
|
|
291
225
|
track: maxTrack + 1 + layers.length,
|
|
@@ -347,7 +281,6 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
347
281
|
selectorIndex,
|
|
348
282
|
sourceFile,
|
|
349
283
|
});
|
|
350
|
-
const compositionContext = resolveDomCompositionContext(el, rootComp);
|
|
351
284
|
const entry: TimelineElement = {
|
|
352
285
|
id: identity.id,
|
|
353
286
|
label,
|
|
@@ -356,17 +289,13 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
356
289
|
start,
|
|
357
290
|
duration: dur,
|
|
358
291
|
track: isNaN(track) ? 0 : track,
|
|
359
|
-
zIndex: getTimelineElementZIndex(el),
|
|
360
|
-
hasExplicitZIndex: getTimelineElementHasExplicitZIndex(el),
|
|
361
|
-
stackingContextId: compositionContext.stackingContextId,
|
|
362
|
-
parentCompositionId: compositionContext.parentCompositionId,
|
|
363
|
-
compositionAncestors: compositionContext.compositionAncestors,
|
|
364
292
|
domId: el.id || undefined,
|
|
365
293
|
hfId: el.getAttribute("data-hf-id") || undefined,
|
|
366
294
|
selector,
|
|
367
295
|
selectorIndex,
|
|
368
296
|
sourceFile,
|
|
369
297
|
timingSource: "authored",
|
|
298
|
+
zIndex: readTimelineElementZIndex(el),
|
|
370
299
|
};
|
|
371
300
|
|
|
372
301
|
const mediaEl = resolveMediaElement(el);
|
|
@@ -437,7 +366,14 @@ export function mergeTimelineElementsPreservingDowngrades(
|
|
|
437
366
|
|
|
438
367
|
const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
|
|
439
368
|
const preserved = currentElements.filter(
|
|
440
|
-
(element) =>
|
|
369
|
+
(element) =>
|
|
370
|
+
!nextIdentities.has(getTimelineElementIdentity(element)) &&
|
|
371
|
+
// Only preserve enriched sub-composition children (compositionSrc set),
|
|
372
|
+
// which a bare DOM re-scan legitimately drops and enrichMissingCompositions
|
|
373
|
+
// re-adds. A TOP-LEVEL element missing from the fresh scan was genuinely
|
|
374
|
+
// removed (undo of a split, a delete), so let it go — otherwise undoing a
|
|
375
|
+
// split leaves a ghost clip in the timeline even though the file is reverted.
|
|
376
|
+
element.compositionSrc != null,
|
|
441
377
|
);
|
|
442
378
|
if (preserved.length === 0) return nextElements;
|
|
443
379
|
return [...nextElements, ...preserved];
|
|
@@ -481,11 +417,6 @@ export function buildStandaloneRootTimelineElement(params: {
|
|
|
481
417
|
start: 0,
|
|
482
418
|
duration: params.rootDuration,
|
|
483
419
|
track: 0,
|
|
484
|
-
zIndex: 0,
|
|
485
|
-
hasExplicitZIndex: false,
|
|
486
|
-
stackingContextId: params.compositionId,
|
|
487
|
-
parentCompositionId: null,
|
|
488
|
-
compositionAncestors: [params.compositionId],
|
|
489
420
|
compositionSrc,
|
|
490
421
|
selector: params.selector,
|
|
491
422
|
selectorIndex: params.selectorIndex,
|
|
@@ -15,6 +15,31 @@ import { isFinitePositive } from "./playbackAdapter";
|
|
|
15
15
|
// Duration attribute helpers
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Read a host element's effective CSS stacking order for the timeline's reverse
|
|
20
|
+
* z→lane mapping. Prefers the inline `style.zIndex` (what the canvas context
|
|
21
|
+
* menu and LayersPanel z-edits write via handleDomZIndexReorderCommit), falls
|
|
22
|
+
* back to computed style; "auto" / empty / unparseable ⇒ 0. Works with a
|
|
23
|
+
* detached parse Document (no defaultView) as well as a live iframe. Mirrors
|
|
24
|
+
* canvasContextMenuZOrder.parseZIndex semantics so the two directions agree.
|
|
25
|
+
*/
|
|
26
|
+
export function readTimelineElementZIndex(el: Element): number {
|
|
27
|
+
const html = el as HTMLElement;
|
|
28
|
+
const parseZ = (value: string | null | undefined): number | null => {
|
|
29
|
+
if (value == null || value === "" || value === "auto") return null;
|
|
30
|
+
const n = Number.parseInt(value, 10);
|
|
31
|
+
return Number.isFinite(n) ? n : null;
|
|
32
|
+
};
|
|
33
|
+
const fromInline = parseZ(html.style?.zIndex);
|
|
34
|
+
if (fromInline != null) return fromInline;
|
|
35
|
+
const view = el.ownerDocument?.defaultView;
|
|
36
|
+
if (view?.getComputedStyle) {
|
|
37
|
+
const fromComputed = parseZ(view.getComputedStyle(html).zIndex);
|
|
38
|
+
if (fromComputed != null) return fromComputed;
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
18
43
|
function readDurationAttribute(el: Element | null | undefined): number {
|
|
19
44
|
if (!el) return 0;
|
|
20
45
|
const duration =
|
|
@@ -36,14 +61,20 @@ export function isTimelineIgnoredElement(el: Element): boolean {
|
|
|
36
61
|
);
|
|
37
62
|
}
|
|
38
63
|
|
|
39
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Furthest clip end (start + RAW `data-duration`) over every non-root clip in the
|
|
66
|
+
* document. Reads the authored attribute, NOT any runtime-computed value — so it
|
|
67
|
+
* is immune to the runtime's clamp that truncates a clip's live duration to the
|
|
68
|
+
* composition length. This is the source of truth for content-driven duration:
|
|
69
|
+
* computing it from the store instead would feed the truncated value back in and
|
|
70
|
+
* make the composition length ratchet down (research HANDOFF-3 §6.1 feedback loop).
|
|
71
|
+
*/
|
|
72
|
+
export function furthestClipEndFromDocument(doc: Document | null | undefined): number {
|
|
40
73
|
if (!doc) return 0;
|
|
41
|
-
const
|
|
42
|
-
if (rootDuration > 0) return rootDuration;
|
|
43
|
-
|
|
74
|
+
const root = doc.querySelector("[data-composition-id]");
|
|
44
75
|
let maxEnd = 0;
|
|
45
76
|
for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
|
|
46
|
-
if (isTimelineIgnoredElement(node)) continue;
|
|
77
|
+
if (node === root || isTimelineIgnoredElement(node)) continue;
|
|
47
78
|
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
|
48
79
|
const duration = readDurationAttribute(node);
|
|
49
80
|
if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
|
|
@@ -52,6 +83,24 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine
|
|
|
52
83
|
return maxEnd;
|
|
53
84
|
}
|
|
54
85
|
|
|
86
|
+
export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
|
|
87
|
+
if (!doc) return 0;
|
|
88
|
+
const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
|
|
89
|
+
if (rootDuration > 0) return rootDuration;
|
|
90
|
+
return furthestClipEndFromDocument(doc);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Furthest clip end parsed straight from a composition SOURCE STRING (the HTML
|
|
95
|
+
* being saved). Uses raw `data-duration`, so it is the correct input for syncing
|
|
96
|
+
* the root duration after an edit — reading the store instead would use the
|
|
97
|
+
* runtime-truncated durations and shrink the composition (the feedback loop).
|
|
98
|
+
*/
|
|
99
|
+
export function furthestClipEndFromSource(source: string): number {
|
|
100
|
+
if (!source) return 0;
|
|
101
|
+
return furthestClipEndFromDocument(new DOMParser().parseFromString(source, "text/html"));
|
|
102
|
+
}
|
|
103
|
+
|
|
55
104
|
// ---------------------------------------------------------------------------
|
|
56
105
|
// DOM element type guards
|
|
57
106
|
// ---------------------------------------------------------------------------
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
getTimelineElementSelectorIndex,
|
|
19
19
|
getTimelineElementDisplayLabel,
|
|
20
20
|
buildTimelineElementIdentity,
|
|
21
|
+
readTimelineElementZIndex,
|
|
21
22
|
} from "./timelineElementHelpers";
|
|
22
23
|
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
@@ -377,6 +378,7 @@ export function buildMissingCompositionElements(
|
|
|
377
378
|
selector,
|
|
378
379
|
selectorIndex,
|
|
379
380
|
sourceFile,
|
|
381
|
+
zIndex: readTimelineElementZIndex(el),
|
|
380
382
|
};
|
|
381
383
|
if (compSrc) {
|
|
382
384
|
entry.compositionSrc = compSrc;
|
|
@@ -3,6 +3,7 @@ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
|
3
3
|
import type { BeatEditState } from "../../utils/beatEditing";
|
|
4
4
|
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
5
5
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
6
|
+
import { computePinnedZoomPercent } from "../components/timelineZoom";
|
|
6
7
|
|
|
7
8
|
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
|
8
9
|
export interface KeyframeCacheEntry {
|
|
@@ -75,6 +76,10 @@ export interface TimelineElement {
|
|
|
75
76
|
export type ZoomMode = "fit" | "manual";
|
|
76
77
|
type TimelineTool = "select" | "razor";
|
|
77
78
|
|
|
79
|
+
export interface SelectElementOptions {
|
|
80
|
+
preserveSet?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
function resolveElementSelection(
|
|
79
84
|
ids: Iterable<string>,
|
|
80
85
|
anchor?: string | null,
|
|
@@ -142,6 +147,25 @@ interface PlayerState {
|
|
|
142
147
|
|
|
143
148
|
/** Multi-select: additional selected elements beyond selectedElementId. */
|
|
144
149
|
selectedElementIds: Set<string>;
|
|
150
|
+
clearSelectedElementIds: () => void;
|
|
151
|
+
/** Replace the whole multi-selection at once (marquee live updates). */
|
|
152
|
+
setSelectedElementIds: (ids: Set<string>) => void;
|
|
153
|
+
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
|
|
154
|
+
timelineSnapEnabled: boolean;
|
|
155
|
+
setTimelineSnapEnabled: (enabled: boolean) => void;
|
|
156
|
+
/**
|
|
157
|
+
* Pin the timeline zoom to its current visual scale before a duration-changing
|
|
158
|
+
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
|
159
|
+
* rescaling every clip. No-op once already pinned (mode is "manual").
|
|
160
|
+
*/
|
|
161
|
+
pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
|
|
162
|
+
/**
|
|
163
|
+
* The timeline's live pixels-per-second + fit basis, published by <Timeline> on
|
|
164
|
+
* every render. Non-reactive scratch state (never read as a render input).
|
|
165
|
+
*/
|
|
166
|
+
timelinePps: number;
|
|
167
|
+
timelineFitPps: number;
|
|
168
|
+
setTimelineScale: (pps: number, fitPps: number) => void;
|
|
145
169
|
setSelection: (ids: Iterable<string>, anchor?: string | null) => void;
|
|
146
170
|
addSelectedElementId: (id: string) => void;
|
|
147
171
|
toggleSelectedElementId: (id: string) => void;
|
|
@@ -160,7 +184,7 @@ interface PlayerState {
|
|
|
160
184
|
setTimelineReady: (ready: boolean) => void;
|
|
161
185
|
setBeatDragging: (dragging: boolean) => void;
|
|
162
186
|
setElements: (elements: TimelineElement[]) => void;
|
|
163
|
-
setSelectedElementId: (id: string | null) => void;
|
|
187
|
+
setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
164
188
|
/** Move the selection anchor within an active multi-selection without collapsing it. */
|
|
165
189
|
setSelectionAnchor: (id: string | null) => void;
|
|
166
190
|
updateElement: (
|
|
@@ -260,6 +284,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
260
284
|
loopEnabled: false,
|
|
261
285
|
zoomMode: "fit",
|
|
262
286
|
manualZoomPercent: 100,
|
|
287
|
+
timelinePps: 100,
|
|
288
|
+
timelineFitPps: 100,
|
|
263
289
|
inPoint: null,
|
|
264
290
|
outPoint: null,
|
|
265
291
|
|
|
@@ -383,6 +409,32 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
383
409
|
},
|
|
384
410
|
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
|
385
411
|
setZoomMode: (mode) => set({ zoomMode: mode }),
|
|
412
|
+
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
|
|
413
|
+
setSelectedElementIds: (ids: Set<string>) => set({ selectedElementIds: new Set(ids) }),
|
|
414
|
+
timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true,
|
|
415
|
+
setTimelineSnapEnabled: (enabled) => {
|
|
416
|
+
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
|
|
417
|
+
set({ timelineSnapEnabled: enabled });
|
|
418
|
+
},
|
|
419
|
+
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
|
|
420
|
+
set((s) => {
|
|
421
|
+
// Already pinned (or the user manually zoomed) — never clobber that.
|
|
422
|
+
if (s.zoomMode === "manual") return {};
|
|
423
|
+
const percent = computePinnedZoomPercent(currentPixelsPerSecond, fitPixelsPerSecond);
|
|
424
|
+
writeStudioUiPreferences({
|
|
425
|
+
timelineZoomMode: "manual",
|
|
426
|
+
timelineManualZoomPercent: percent,
|
|
427
|
+
});
|
|
428
|
+
return { zoomMode: "manual", manualZoomPercent: percent };
|
|
429
|
+
}),
|
|
430
|
+
setTimelineScale: (pps, fitPps) => {
|
|
431
|
+
// Non-reactive publish: mutate in place + reuse the same object identity so no
|
|
432
|
+
// subscriber re-renders (these fields are never a render input, only read
|
|
433
|
+
// imperatively before pinning).
|
|
434
|
+
const state = get();
|
|
435
|
+
state.timelinePps = pps;
|
|
436
|
+
state.timelineFitPps = fitPps;
|
|
437
|
+
},
|
|
386
438
|
setInPoint: (time) =>
|
|
387
439
|
set((state) => {
|
|
388
440
|
const t = time !== null && Number.isFinite(time) ? time : null;
|
|
@@ -414,9 +466,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
414
466
|
// A genuine single selection: always collapse the set to just this element. User
|
|
415
467
|
// intent (timeline click, preview click via applyDomSelection) flows here; DOM sync
|
|
416
468
|
// echoes that must preserve a group go through setSelectionAnchor instead.
|
|
417
|
-
setSelectedElementId: (id) =>
|
|
469
|
+
setSelectedElementId: (id, options) =>
|
|
418
470
|
set((s) => {
|
|
419
|
-
const
|
|
471
|
+
const preserveSet = Boolean(options?.preserveSet && id && s.selectedElementIds.has(id));
|
|
472
|
+
const selectedElementIds = preserveSet
|
|
473
|
+
? new Set(s.selectedElementIds)
|
|
474
|
+
: options?.preserveSet
|
|
475
|
+
? new Set<string>()
|
|
476
|
+
: id
|
|
477
|
+
? new Set([id])
|
|
478
|
+
: new Set<string>();
|
|
420
479
|
// Selecting a different element drops any active keyframe selection — otherwise
|
|
421
480
|
// a stale activeKeyframePct from a prior diamond click would force the next drag
|
|
422
481
|
// to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
|