@hyperframes/studio 0.7.55 → 0.7.57
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/hyperframes-player--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.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 +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- 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 +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- 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 +126 -125
- 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 +53 -2
- 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 +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- 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/propertyPanelColorGradingSection.tsx +15 -21
- 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/useCanvasContextMenuState.ts +92 -0
- 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/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -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 +260 -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.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -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 +56 -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 +23 -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 +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -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/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -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 +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- 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 +148 -18
- 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/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- 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/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- 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/TimelineRuler.tsx +58 -48
- 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 +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -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 +42 -2
- 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 +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- 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/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- 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 +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- 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/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- 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,8 +1,21 @@
|
|
|
1
|
-
import { useRef, useState, useCallback } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { useRef, useState, useCallback, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buildClipRangeSelection,
|
|
4
|
+
applyTimelineAutoScrollStep,
|
|
5
|
+
resolveTimelineAutoScrollLoopAction,
|
|
6
|
+
type TimelineRangeSelection,
|
|
7
|
+
} from "./timelineEditing";
|
|
3
8
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
-
import { liveTime } from "../store/playerStore";
|
|
9
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
5
10
|
import { GUTTER } from "./timelineLayout";
|
|
11
|
+
import {
|
|
12
|
+
computeMarqueeSelection,
|
|
13
|
+
getMarqueeRect,
|
|
14
|
+
isMarqueeDrag,
|
|
15
|
+
isTimelineRulerPress,
|
|
16
|
+
type MarqueeClipInput,
|
|
17
|
+
} from "./timelineMarquee";
|
|
18
|
+
import type { Rect } from "../../utils/marqueeGeometry";
|
|
6
19
|
|
|
7
20
|
interface UseTimelineRangeSelectionInput {
|
|
8
21
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
@@ -15,11 +28,69 @@ interface UseTimelineRangeSelectionInput {
|
|
|
15
28
|
dragScrollRaf: React.RefObject<number>;
|
|
16
29
|
isDragging: React.RefObject<boolean>;
|
|
17
30
|
setShowPopover: (v: boolean) => void;
|
|
31
|
+
elementsRef: React.RefObject<TimelineElement[]>;
|
|
32
|
+
trackOrderRef: React.RefObject<number[]>;
|
|
33
|
+
onSelectElement?: (element: TimelineElement | null) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface MarqueeDragState {
|
|
37
|
+
originX: number;
|
|
38
|
+
originY: number;
|
|
39
|
+
/** Pre-drag selection, restored on Escape-cancel. */
|
|
40
|
+
baseIds: Set<string>;
|
|
41
|
+
basePrimary: string | null;
|
|
42
|
+
/** Union new hits with baseIds (shift/cmd/ctrl at pointerdown). */
|
|
43
|
+
additive: boolean;
|
|
44
|
+
/** True once the pointer travelled past the click threshold. */
|
|
45
|
+
active: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function snapshotSelection(): { ids: Set<string>; primary: string | null } {
|
|
49
|
+
const s = usePlayerStore.getState();
|
|
50
|
+
const ids = new Set(s.selectedElementIds);
|
|
51
|
+
if (s.selectedElementId) ids.add(s.selectedElementId);
|
|
52
|
+
return { ids, primary: s.selectedElementId };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toMarqueeClips(elements: TimelineElement[]): MarqueeClipInput[] {
|
|
56
|
+
return elements.map((el) => ({
|
|
57
|
+
id: el.key ?? el.id,
|
|
58
|
+
start: el.start,
|
|
59
|
+
duration: el.duration,
|
|
60
|
+
track: el.track,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compute the live selection for a marquee rect and commit it to the store.
|
|
66
|
+
* Shift held mid-drag (or cmd/ctrl at pointerdown) unions the new hits with the
|
|
67
|
+
* pre-drag selection (marquee.baseIds / basePrimary).
|
|
68
|
+
*/
|
|
69
|
+
function commitMarqueeSelection(
|
|
70
|
+
rect: Rect,
|
|
71
|
+
additive: boolean,
|
|
72
|
+
marquee: MarqueeDragState,
|
|
73
|
+
elements: TimelineElement[],
|
|
74
|
+
trackOrder: number[],
|
|
75
|
+
pps: number,
|
|
76
|
+
): void {
|
|
77
|
+
const { ids, primaryId } = computeMarqueeSelection({
|
|
78
|
+
clips: toMarqueeClips(elements),
|
|
79
|
+
trackOrder,
|
|
80
|
+
pps,
|
|
81
|
+
marquee: rect,
|
|
82
|
+
baseSelection: additive ? marquee.baseIds : undefined,
|
|
83
|
+
});
|
|
84
|
+
const store = usePlayerStore.getState();
|
|
85
|
+
// Primary FIRST: setSelectedElementId collapses the multi-select set, so the set
|
|
86
|
+
// must be written after it or the marquee selection would be wiped every frame.
|
|
87
|
+
store.setSelectedElementId(primaryId ?? (additive ? marquee.basePrimary : null));
|
|
88
|
+
store.setSelectedElementIds(ids);
|
|
18
89
|
}
|
|
19
90
|
|
|
20
91
|
export function useTimelineRangeSelection({
|
|
21
92
|
scrollRef,
|
|
22
|
-
ppsRef
|
|
93
|
+
ppsRef,
|
|
23
94
|
effectiveDuration: _effectiveDuration,
|
|
24
95
|
pps,
|
|
25
96
|
onSeek: _onSeek,
|
|
@@ -28,9 +99,15 @@ export function useTimelineRangeSelection({
|
|
|
28
99
|
dragScrollRaf,
|
|
29
100
|
isDragging,
|
|
30
101
|
setShowPopover,
|
|
102
|
+
elementsRef,
|
|
103
|
+
trackOrderRef,
|
|
104
|
+
onSelectElement,
|
|
31
105
|
}: UseTimelineRangeSelectionInput) {
|
|
32
106
|
const isRangeSelecting = useRef(false);
|
|
33
107
|
const rangeAnchorTime = useRef(0);
|
|
108
|
+
// Reactive mirror of the scrub gesture (isDragging is a ref, so it can't drive
|
|
109
|
+
// rendering). Drives the playhead head's filled-vs-hollow state.
|
|
110
|
+
const [isScrubbing, setIsScrubbing] = useState(false);
|
|
34
111
|
const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
|
|
35
112
|
const shiftClickClipRef = useRef<{
|
|
36
113
|
element: TimelineElement;
|
|
@@ -41,55 +118,174 @@ export function useTimelineRangeSelection({
|
|
|
41
118
|
const seekRafRef = useRef(0);
|
|
42
119
|
const pendingClientXRef = useRef(0);
|
|
43
120
|
|
|
121
|
+
// Marquee (rubber-band) multi-select on the empty timeline body.
|
|
122
|
+
const marqueeRef = useRef<MarqueeDragState | null>(null);
|
|
123
|
+
const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
|
|
124
|
+
// Edge auto-scroll during a marquee drag: last pointer position (client-space)
|
|
125
|
+
// + shift flag, re-applied each RAF frame while the view scrolls under a
|
|
126
|
+
// stationary pointer, so the marquee can extend past the visible area.
|
|
127
|
+
const marqueePointerRef = useRef<{ clientX: number; clientY: number; shiftKey: boolean } | null>(
|
|
128
|
+
null,
|
|
129
|
+
);
|
|
130
|
+
const marqueeScrollRaf = useRef(0);
|
|
131
|
+
|
|
132
|
+
/** Pointer position → canvas/content coordinates (same space as clip rects). */
|
|
133
|
+
const toContentPoint = useCallback(
|
|
134
|
+
(clientX: number, clientY: number): { x: number; y: number } | null => {
|
|
135
|
+
const el = scrollRef.current;
|
|
136
|
+
if (!el) return null;
|
|
137
|
+
const rect = el.getBoundingClientRect();
|
|
138
|
+
return {
|
|
139
|
+
x: clientX - rect.left + el.scrollLeft,
|
|
140
|
+
y: clientY - rect.top + el.scrollTop,
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
[scrollRef],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Recompute the marquee rect + live selection for a client-space pointer.
|
|
147
|
+
// Content-space (folds in scrollLeft/scrollTop), so re-running it after the
|
|
148
|
+
// view scrolls naturally extends the rect toward the newly revealed area.
|
|
149
|
+
// Shared by the pointermove handler and the edge auto-scroll stepper.
|
|
150
|
+
const applyMarqueeAtClient = useCallback(
|
|
151
|
+
(clientX: number, clientY: number, shiftKey: boolean) => {
|
|
152
|
+
const marquee = marqueeRef.current;
|
|
153
|
+
if (!marquee) return;
|
|
154
|
+
const point = toContentPoint(clientX, clientY);
|
|
155
|
+
if (!point) return;
|
|
156
|
+
if (!marquee.active && !isMarqueeDrag(marquee.originX, marquee.originY, point.x, point.y)) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
marquee.active = true;
|
|
160
|
+
const rect = getMarqueeRect(marquee.originX, marquee.originY, point.x, point.y);
|
|
161
|
+
setMarqueeRect(rect);
|
|
162
|
+
// Live selection: every clip the box currently covers. Shift held
|
|
163
|
+
// mid-drag (or cmd/ctrl at pointerdown) adds to the prior selection.
|
|
164
|
+
const additive = marquee.additive || shiftKey;
|
|
165
|
+
commitMarqueeSelection(
|
|
166
|
+
rect,
|
|
167
|
+
additive,
|
|
168
|
+
marquee,
|
|
169
|
+
elementsRef.current ?? [],
|
|
170
|
+
trackOrderRef.current ?? [],
|
|
171
|
+
ppsRef.current,
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
[toContentPoint, elementsRef, trackOrderRef, ppsRef],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const stopMarqueeAutoScroll = useCallback(() => {
|
|
178
|
+
marqueePointerRef.current = null;
|
|
179
|
+
if (marqueeScrollRaf.current) {
|
|
180
|
+
cancelAnimationFrame(marqueeScrollRaf.current);
|
|
181
|
+
marqueeScrollRaf.current = 0;
|
|
182
|
+
}
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
// Edge auto-scroll while marquee-dragging: mirrors stepClipDragAutoScroll —
|
|
186
|
+
// scroll the container toward the edge zone the pointer is in, then re-run the
|
|
187
|
+
// marquee at the (unchanged) client pointer so the rect + selection extend
|
|
188
|
+
// under the scroll delta. Self-perpetuating RAF until the pointer leaves the
|
|
189
|
+
// edge zones or the gesture ends.
|
|
190
|
+
const stepMarqueeAutoScroll = useCallback(() => {
|
|
191
|
+
marqueeScrollRaf.current = 0;
|
|
192
|
+
const marquee = marqueeRef.current;
|
|
193
|
+
const pointer = marqueePointerRef.current;
|
|
194
|
+
const scroll = scrollRef.current;
|
|
195
|
+
if (!marquee || !pointer || !scroll) return;
|
|
196
|
+
if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
|
|
197
|
+
|
|
198
|
+
// Re-run at the SAME client point: toContentPoint folds in the new scroll, so
|
|
199
|
+
// the marquee's moving corner tracks the revealed content.
|
|
200
|
+
applyMarqueeAtClient(pointer.clientX, pointer.clientY, pointer.shiftKey);
|
|
201
|
+
marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
|
|
202
|
+
}, [scrollRef, applyMarqueeAtClient]);
|
|
203
|
+
|
|
204
|
+
const syncMarqueeAutoScroll = useCallback(
|
|
205
|
+
(clientX: number, clientY: number, shiftKey: boolean) => {
|
|
206
|
+
marqueePointerRef.current = { clientX, clientY, shiftKey };
|
|
207
|
+
const action = resolveTimelineAutoScrollLoopAction(
|
|
208
|
+
scrollRef.current,
|
|
209
|
+
clientX,
|
|
210
|
+
clientY,
|
|
211
|
+
marqueeScrollRaf.current !== 0,
|
|
212
|
+
);
|
|
213
|
+
if (action === "stop") {
|
|
214
|
+
cancelAnimationFrame(marqueeScrollRaf.current);
|
|
215
|
+
marqueeScrollRaf.current = 0;
|
|
216
|
+
} else if (action === "start") {
|
|
217
|
+
marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
[scrollRef, stepMarqueeAutoScroll],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Shift-press → start a time-range selection anchored at the pressed x.
|
|
224
|
+
const beginRangeSelection = useCallback(
|
|
225
|
+
(e: React.PointerEvent) => {
|
|
226
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
227
|
+
isRangeSelecting.current = true;
|
|
228
|
+
setShowPopover(false);
|
|
229
|
+
const rect = scrollRef.current?.getBoundingClientRect();
|
|
230
|
+
if (rect) {
|
|
231
|
+
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
|
|
232
|
+
const time = Math.max(0, x / pps);
|
|
233
|
+
rangeAnchorTime.current = time;
|
|
234
|
+
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
[scrollRef, pps, setShowPopover],
|
|
238
|
+
);
|
|
239
|
+
|
|
44
240
|
const handlePointerDown = useCallback(
|
|
45
241
|
(e: React.PointerEvent) => {
|
|
46
242
|
if (e.button !== 0) return;
|
|
47
243
|
if (e.shiftKey) {
|
|
48
|
-
(e
|
|
49
|
-
isRangeSelecting.current = true;
|
|
50
|
-
setShowPopover(false);
|
|
51
|
-
const rect = scrollRef.current?.getBoundingClientRect();
|
|
52
|
-
if (rect) {
|
|
53
|
-
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
|
|
54
|
-
const time = Math.max(0, x / pps);
|
|
55
|
-
rangeAnchorTime.current = time;
|
|
56
|
-
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
|
|
57
|
-
}
|
|
244
|
+
beginRangeSelection(e);
|
|
58
245
|
return;
|
|
59
246
|
}
|
|
60
247
|
shiftClickClipRef.current = null;
|
|
61
248
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
62
249
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
63
|
-
isDragging.current = true;
|
|
64
250
|
setRangeSelection(null);
|
|
65
251
|
setShowPopover(false);
|
|
66
|
-
|
|
252
|
+
const point = toContentPoint(e.clientX, e.clientY);
|
|
253
|
+
// Ruler press → scrub the playhead (the standard scrub surface). The
|
|
254
|
+
// ruler is sticky, so this decision uses VIEWPORT-space y — content-space
|
|
255
|
+
// y (which folds in scrollTop) breaks once the body is scrolled down and
|
|
256
|
+
// the stuck ruler visually overlays scrolled-away track rows.
|
|
257
|
+
const scrollRect = scrollRef.current?.getBoundingClientRect();
|
|
258
|
+
if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) {
|
|
259
|
+
isDragging.current = true;
|
|
260
|
+
setIsScrubbing(true);
|
|
261
|
+
seekFromX(e.clientX);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Empty body press → pending marquee. A plain click (no drag past the
|
|
265
|
+
// threshold) deselects on pointerup; a drag draws the marquee. Never scrubs.
|
|
266
|
+
const base = snapshotSelection();
|
|
267
|
+
marqueeRef.current = {
|
|
268
|
+
originX: point.x,
|
|
269
|
+
originY: point.y,
|
|
270
|
+
baseIds: base.ids,
|
|
271
|
+
basePrimary: base.primary,
|
|
272
|
+
additive: e.metaKey || e.ctrlKey,
|
|
273
|
+
active: false,
|
|
274
|
+
};
|
|
67
275
|
},
|
|
68
|
-
[
|
|
276
|
+
[beginRangeSelection, seekFromX, scrollRef, isDragging, setShowPopover, toContentPoint],
|
|
69
277
|
);
|
|
70
278
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (rect) {
|
|
76
|
-
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
|
|
77
|
-
setRangeSelection((prev) =>
|
|
78
|
-
prev
|
|
79
|
-
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
|
80
|
-
: null,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
if (!isDragging.current) return;
|
|
86
|
-
pendingClientXRef.current = e.clientX;
|
|
279
|
+
// Scrub-drag update: live playhead feedback (liveTime) + RAF-throttled seek.
|
|
280
|
+
const updateScrubDrag = useCallback(
|
|
281
|
+
(clientX: number) => {
|
|
282
|
+
pendingClientXRef.current = clientX;
|
|
87
283
|
// Update the playhead visual immediately via liveTime for smooth feedback,
|
|
88
284
|
// then RAF-throttle the full seek (adapter + React state sync).
|
|
89
285
|
const el = scrollRef.current;
|
|
90
286
|
if (el) {
|
|
91
287
|
const rect = el.getBoundingClientRect();
|
|
92
|
-
const x =
|
|
288
|
+
const x = clientX - rect.left + el.scrollLeft - GUTTER;
|
|
93
289
|
if (x >= 0) {
|
|
94
290
|
const dur = el.scrollWidth / pps;
|
|
95
291
|
liveTime.notify(Math.max(0, Math.min(dur, x / pps)));
|
|
@@ -105,40 +301,135 @@ export function useTimelineRangeSelection({
|
|
|
105
301
|
});
|
|
106
302
|
}
|
|
107
303
|
},
|
|
108
|
-
[
|
|
304
|
+
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const handlePointerMove = useCallback(
|
|
308
|
+
(e: React.PointerEvent) => {
|
|
309
|
+
if (isRangeSelecting.current) {
|
|
310
|
+
const rect = scrollRef.current?.getBoundingClientRect();
|
|
311
|
+
if (rect) {
|
|
312
|
+
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
|
|
313
|
+
setRangeSelection((prev) =>
|
|
314
|
+
prev
|
|
315
|
+
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
|
316
|
+
: null,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const marquee = marqueeRef.current;
|
|
322
|
+
if (marquee) {
|
|
323
|
+
applyMarqueeAtClient(e.clientX, e.clientY, e.shiftKey);
|
|
324
|
+
// Edge auto-scroll: once the drag is live, scroll when the pointer nears
|
|
325
|
+
// a viewport edge so the marquee can extend past the visible area.
|
|
326
|
+
if (marquee.active) syncMarqueeAutoScroll(e.clientX, e.clientY, e.shiftKey);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!isDragging.current) return;
|
|
330
|
+
updateScrubDrag(e.clientX);
|
|
331
|
+
},
|
|
332
|
+
[pps, scrollRef, isDragging, applyMarqueeAtClient, syncMarqueeAutoScroll, updateScrubDrag],
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// Release of a shift time-range gesture: keep a real range (or a shift-click
|
|
336
|
+
// clip range), otherwise clear it.
|
|
337
|
+
const finishRangeSelection = useCallback(() => {
|
|
338
|
+
isRangeSelecting.current = false;
|
|
339
|
+
const pendingShiftClick = shiftClickClipRef.current;
|
|
340
|
+
shiftClickClipRef.current = null;
|
|
341
|
+
setRangeSelection((prev) => {
|
|
342
|
+
if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
|
|
343
|
+
setShowPopover(true);
|
|
344
|
+
return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
|
|
345
|
+
}
|
|
346
|
+
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
|
|
347
|
+
setShowPopover(true);
|
|
348
|
+
return prev;
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
});
|
|
352
|
+
}, [setShowPopover]);
|
|
353
|
+
|
|
354
|
+
// Release of a marquee gesture: plain click deselects; a real drag keeps the
|
|
355
|
+
// live selection and notifies the primary element.
|
|
356
|
+
const finishMarquee = useCallback(
|
|
357
|
+
(marquee: MarqueeDragState) => {
|
|
358
|
+
marqueeRef.current = null;
|
|
359
|
+
stopMarqueeAutoScroll();
|
|
360
|
+
setMarqueeRect(null);
|
|
361
|
+
const store = usePlayerStore.getState();
|
|
362
|
+
if (!marquee.active) {
|
|
363
|
+
// Plain click on empty body (click-away): deselect everything.
|
|
364
|
+
store.setSelectedElementId(null);
|
|
365
|
+
store.clearSelectedElementIds();
|
|
366
|
+
onSelectElement?.(null);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const primaryKey = store.selectedElementId;
|
|
370
|
+
const primary =
|
|
371
|
+
(elementsRef.current ?? []).find((el) => (el.key ?? el.id) === primaryKey) ?? null;
|
|
372
|
+
onSelectElement?.(primary);
|
|
373
|
+
},
|
|
374
|
+
[stopMarqueeAutoScroll, elementsRef, onSelectElement],
|
|
109
375
|
);
|
|
110
376
|
|
|
111
377
|
const handlePointerUp = useCallback(() => {
|
|
112
378
|
if (isRangeSelecting.current) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
|
|
120
|
-
}
|
|
121
|
-
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
|
|
122
|
-
setShowPopover(true);
|
|
123
|
-
return prev;
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
});
|
|
379
|
+
finishRangeSelection();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const marquee = marqueeRef.current;
|
|
383
|
+
if (marquee) {
|
|
384
|
+
finishMarquee(marquee);
|
|
127
385
|
return;
|
|
128
386
|
}
|
|
387
|
+
if (!isDragging.current) return;
|
|
129
388
|
if (seekRafRef.current) {
|
|
130
389
|
cancelAnimationFrame(seekRafRef.current);
|
|
131
390
|
seekRafRef.current = 0;
|
|
132
391
|
}
|
|
133
392
|
seekFromX(pendingClientXRef.current);
|
|
134
393
|
isDragging.current = false;
|
|
394
|
+
setIsScrubbing(false);
|
|
135
395
|
cancelAnimationFrame(dragScrollRaf.current);
|
|
136
|
-
}, [isDragging, dragScrollRaf,
|
|
396
|
+
}, [isDragging, dragScrollRaf, seekFromX, finishRangeSelection, finishMarquee]);
|
|
397
|
+
|
|
398
|
+
// Escape: cancel an in-flight marquee (restores the pre-drag selection);
|
|
399
|
+
// otherwise clear any lingering multi-selection.
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
402
|
+
if (e.key !== "Escape") return;
|
|
403
|
+
const store = usePlayerStore.getState();
|
|
404
|
+
const marquee = marqueeRef.current;
|
|
405
|
+
if (marquee) {
|
|
406
|
+
marqueeRef.current = null;
|
|
407
|
+
stopMarqueeAutoScroll();
|
|
408
|
+
setMarqueeRect(null);
|
|
409
|
+
if (marquee.active) {
|
|
410
|
+
// Primary FIRST (see commitMarqueeSelection): it collapses the set, so
|
|
411
|
+
// restore the pre-drag primary before repopulating the base ids.
|
|
412
|
+
store.setSelectedElementId(marquee.basePrimary);
|
|
413
|
+
store.setSelectedElementIds(marquee.baseIds);
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// Escape with no marquee clears the whole selection — primary AND set.
|
|
418
|
+
// setSelectedElementId(null) also collapses the multi-select set.
|
|
419
|
+
if (store.selectedElementId || store.selectedElementIds.size > 0) {
|
|
420
|
+
store.setSelectedElementId(null);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
window.addEventListener("keydown", onKeyDown);
|
|
424
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
425
|
+
}, [stopMarqueeAutoScroll]);
|
|
137
426
|
|
|
138
427
|
return {
|
|
139
428
|
rangeSelection,
|
|
140
429
|
setRangeSelection,
|
|
141
430
|
shiftClickClipRef,
|
|
431
|
+
marqueeRect,
|
|
432
|
+
isScrubbing,
|
|
142
433
|
handlePointerDown,
|
|
143
434
|
handlePointerMove,
|
|
144
435
|
handlePointerUp,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumes playerStore.clipRevealRequest: when another surface (the sidebar
|
|
3
|
+
* asset card / audio row) asks for a clip to be revealed, smooth-scroll the
|
|
4
|
+
* timeline's scroll container so that clip is visible — horizontally to its
|
|
5
|
+
* time and vertically to its lane.
|
|
6
|
+
*
|
|
7
|
+
* The request is consumed (cleared) whether or not the clip node is found, so
|
|
8
|
+
* a stale request can never replay a scroll later. Respects zoom mode: in
|
|
9
|
+
* "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
|
|
10
|
+
* only the vertical axis is scrolled there.
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect } from "react";
|
|
13
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
14
|
+
import { GUTTER, RULER_H } from "./timelineLayout";
|
|
15
|
+
import { computeRevealScroll } from "./timelineRevealScroll";
|
|
16
|
+
|
|
17
|
+
export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
|
|
18
|
+
const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!revealRequest) return;
|
|
22
|
+
// Consume the request first — reveal is one-shot, even when the clip node
|
|
23
|
+
// isn't currently rendered (e.g. drilled into a different composition).
|
|
24
|
+
usePlayerStore.getState().clearClipRevealRequest();
|
|
25
|
+
|
|
26
|
+
const container = scrollRef.current;
|
|
27
|
+
if (!container) return;
|
|
28
|
+
const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
|
|
29
|
+
if (!(clip instanceof HTMLElement)) return;
|
|
30
|
+
|
|
31
|
+
const containerRect = container.getBoundingClientRect();
|
|
32
|
+
const clipRect = clip.getBoundingClientRect();
|
|
33
|
+
const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
|
|
34
|
+
const clipTop = clipRect.top - containerRect.top + container.scrollTop;
|
|
35
|
+
|
|
36
|
+
const target = computeRevealScroll({
|
|
37
|
+
scrollLeft: container.scrollLeft,
|
|
38
|
+
scrollTop: container.scrollTop,
|
|
39
|
+
viewportWidth: container.clientWidth,
|
|
40
|
+
viewportHeight: container.clientHeight,
|
|
41
|
+
clipLeft,
|
|
42
|
+
clipRight: clipLeft + clipRect.width,
|
|
43
|
+
clipTop,
|
|
44
|
+
clipBottom: clipTop + clipRect.height,
|
|
45
|
+
stickyLeft: GUTTER,
|
|
46
|
+
stickyTop: RULER_H,
|
|
47
|
+
allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
|
|
48
|
+
});
|
|
49
|
+
if (target.left === null && target.top === null) return;
|
|
50
|
+
container.scrollTo({
|
|
51
|
+
left: target.left ?? container.scrollLeft,
|
|
52
|
+
top: target.top ?? container.scrollTop,
|
|
53
|
+
behavior: "smooth",
|
|
54
|
+
});
|
|
55
|
+
}, [revealRequest, scrollRef]);
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
6
|
+
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({
|
|
11
|
+
actions: null as null | {
|
|
12
|
+
previewIframeRef: { current: HTMLIFrameElement | null };
|
|
13
|
+
handleDomZIndexReorderCommit: ReturnType<typeof vi.fn>;
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("../../contexts/DomEditContext", () => ({
|
|
18
|
+
useDomEditActionsContextOptional: () => mocks.actions,
|
|
19
|
+
}));
|
|
20
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
21
|
+
useStudioShellContextOptional: () => ({ activeCompPath: "nested.html" }),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
|
25
|
+
|
|
26
|
+
describe("useTimelineStackingSync", () => {
|
|
27
|
+
it("forwards resolved entries and the lane gesture coalesce key", async () => {
|
|
28
|
+
const iframe = document.createElement("iframe");
|
|
29
|
+
document.body.appendChild(iframe);
|
|
30
|
+
const node = iframe.contentDocument!.createElement("div");
|
|
31
|
+
node.setAttribute("data-hf-id", "hf-a");
|
|
32
|
+
iframe.contentDocument!.body.appendChild(node);
|
|
33
|
+
const commit = vi.fn().mockResolvedValue(undefined);
|
|
34
|
+
mocks.actions = { previewIframeRef: { current: iframe }, handleDomZIndexReorderCommit: commit };
|
|
35
|
+
const element: TimelineElement = {
|
|
36
|
+
id: "a",
|
|
37
|
+
key: "a",
|
|
38
|
+
hfId: "hf-a",
|
|
39
|
+
tag: "div",
|
|
40
|
+
start: 0,
|
|
41
|
+
duration: 2,
|
|
42
|
+
track: 0,
|
|
43
|
+
sourceFile: "nested.html",
|
|
44
|
+
};
|
|
45
|
+
let apply: ((patches: Array<{ key: string; zIndex: number }>, key?: string) => unknown) | null =
|
|
46
|
+
null;
|
|
47
|
+
function Harness() {
|
|
48
|
+
apply = useTimelineStackingSync({
|
|
49
|
+
expandedElementsRef: { current: [element] },
|
|
50
|
+
}).applyStackingPatches;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const root = mountReactHarness(<Harness />);
|
|
54
|
+
|
|
55
|
+
await act(async () => {
|
|
56
|
+
await apply!([{ key: "a", zIndex: 8 }], "clip-lane-move:7");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(commit).toHaveBeenCalledWith(
|
|
60
|
+
[
|
|
61
|
+
expect.objectContaining({
|
|
62
|
+
element: node,
|
|
63
|
+
zIndex: 8,
|
|
64
|
+
sourceFile: "nested.html",
|
|
65
|
+
// The patch's store key rides along so the commit updates the store
|
|
66
|
+
// zIndex synchronously on the lane-sync path (and rolls it back on
|
|
67
|
+
// failure) instead of waiting for a preview reload.
|
|
68
|
+
key: "a",
|
|
69
|
+
}),
|
|
70
|
+
],
|
|
71
|
+
"clip-lane-move:7",
|
|
72
|
+
);
|
|
73
|
+
act(() => root.unmount());
|
|
74
|
+
mocks.actions = null;
|
|
75
|
+
iframe.remove();
|
|
76
|
+
});
|
|
77
|
+
});
|