@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,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,67 @@
|
|
|
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
|
+
[expect.objectContaining({ element: node, zIndex: 8, sourceFile: "nested.html" })],
|
|
61
|
+
"clip-lane-move:7",
|
|
62
|
+
);
|
|
63
|
+
act(() => root.unmount());
|
|
64
|
+
mocks.actions = null;
|
|
65
|
+
iframe.remove();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useCallback, type RefObject } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
4
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
5
|
+
import { findElementForSelection } from "../../components/editor/domEditingElement";
|
|
6
|
+
import { readEffectiveZIndex } from "../../components/editor/canvasContextMenuZOrder";
|
|
7
|
+
import type { StackingPatch } from "./timelineStackingSync";
|
|
8
|
+
|
|
9
|
+
interface UseTimelineStackingSyncInput {
|
|
10
|
+
expandedElementsRef: RefObject<TimelineElement[]>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Lane ↔ stacking unification (research/STAGE3-NEEDED-WIRING.md). Provision the
|
|
14
|
+
// two deps commitDraggedClipMove accepts so a lane-change drag also patches the
|
|
15
|
+
// edited clip's z-index. Both read the SAME preview iframe + z-order persist path
|
|
16
|
+
// the canvas right-click menu / LayersPanel use, so a timeline lane move and a
|
|
17
|
+
// menu z-edit produce one shared inline-style commit shape. Optional contexts:
|
|
18
|
+
// outside the NLE (standalone <Timeline>) these are null ⇒ deps undefined ⇒ the
|
|
19
|
+
// commit's z-sync is a no-op (backward compatible).
|
|
20
|
+
export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStackingSyncInput) {
|
|
21
|
+
const domEditActions = useDomEditActionsContextOptional();
|
|
22
|
+
const shell = useStudioShellContextOptional();
|
|
23
|
+
const zSyncPreviewIframeRef = domEditActions?.previewIframeRef ?? null;
|
|
24
|
+
const handleDomZIndexReorderCommit = domEditActions?.handleDomZIndexReorderCommit;
|
|
25
|
+
const zSyncActiveCompPath = shell?.activeCompPath ?? null;
|
|
26
|
+
|
|
27
|
+
// Resolve a TimelineElement to its live iframe HTMLElement via the same
|
|
28
|
+
// hfId ?? id ?? selector[selectorIndex] resolver the timeline's DOM patches use.
|
|
29
|
+
const resolveIframeElement = useCallback(
|
|
30
|
+
(el: TimelineElement): HTMLElement | null => {
|
|
31
|
+
const doc = zSyncPreviewIframeRef?.current?.contentDocument ?? null;
|
|
32
|
+
if (!doc) return null;
|
|
33
|
+
return findElementForSelection(
|
|
34
|
+
doc,
|
|
35
|
+
{
|
|
36
|
+
hfId: el.hfId,
|
|
37
|
+
id: el.domId ?? el.id,
|
|
38
|
+
selector: el.selector,
|
|
39
|
+
selectorIndex: el.selectorIndex,
|
|
40
|
+
sourceFile: el.sourceFile,
|
|
41
|
+
},
|
|
42
|
+
zSyncActiveCompPath,
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
[zSyncPreviewIframeRef, zSyncActiveCompPath],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const readClipZIndex = useCallback(
|
|
49
|
+
(el: TimelineElement): number => {
|
|
50
|
+
const node = resolveIframeElement(el);
|
|
51
|
+
return node ? readEffectiveZIndex(node) : 0;
|
|
52
|
+
},
|
|
53
|
+
[resolveIframeElement],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const applyStackingPatches = useCallback(
|
|
57
|
+
(patches: StackingPatch[], coalesceKey?: string) => {
|
|
58
|
+
if (!handleDomZIndexReorderCommit) return;
|
|
59
|
+
const entries = patches.flatMap((p) => {
|
|
60
|
+
const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key);
|
|
61
|
+
const node = el && resolveIframeElement(el);
|
|
62
|
+
if (!el || !node) return [];
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
element: node,
|
|
66
|
+
zIndex: p.zIndex,
|
|
67
|
+
id: el.domId ?? el.id,
|
|
68
|
+
selector: el.selector,
|
|
69
|
+
selectorIndex: el.selectorIndex,
|
|
70
|
+
sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
});
|
|
74
|
+
if (entries.length) return handleDomZIndexReorderCommit(entries, coalesceKey);
|
|
75
|
+
},
|
|
76
|
+
[handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Engage the z-sync only when the persist path is present (inside the NLE).
|
|
80
|
+
const zSyncEnabled = Boolean(handleDomZIndexReorderCommit && zSyncPreviewIframeRef);
|
|
81
|
+
|
|
82
|
+
return { readClipZIndex, applyStackingPatches, zSyncEnabled };
|
|
83
|
+
}
|
|
@@ -192,12 +192,9 @@ function domSiblingClips(
|
|
|
192
192
|
start: host.start,
|
|
193
193
|
duration: host.duration,
|
|
194
194
|
track: host.track,
|
|
195
|
-
zIndex: 0,
|
|
196
|
-
stackingContextId: host.stackingContextId ?? host.domId ?? host.id ?? null,
|
|
197
195
|
kind: "element",
|
|
198
196
|
tagName: null,
|
|
199
197
|
compositionId: null,
|
|
200
|
-
compositionAncestors: host.compositionAncestors ?? [],
|
|
201
198
|
parentCompositionId: host.id ?? null,
|
|
202
199
|
compositionSrc: host.compositionSrc ?? null,
|
|
203
200
|
assetUrl: null,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { useRef, useCallback } from "react";
|
|
10
10
|
import { useCaptionStore } from "../../captions/store";
|
|
11
11
|
import { shouldIgnorePlaybackShortcutEvent, SHUTTLE_SPEEDS } from "../lib/playbackShortcuts";
|
|
12
|
+
import { canvasNudgeKeysClaimed } from "../../utils/canvasNudgeGate";
|
|
12
13
|
import { usePlayerStore } from "../store/playerStore";
|
|
13
14
|
import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
14
15
|
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
@@ -97,6 +98,10 @@ export function usePlaybackKeyboard({
|
|
|
97
98
|
togglePlay();
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
101
|
+
// A nudgeable canvas selection owns the arrow keys (DomEditOverlay moves
|
|
102
|
+
// the element); frame-stepping would double-handle the same keystroke.
|
|
103
|
+
const arrowStep = e.code === "ArrowLeft" || e.code === "ArrowRight";
|
|
104
|
+
if (arrowStep && canvasNudgeKeysClaimed()) return;
|
|
100
105
|
if (e.code === "ArrowLeft") {
|
|
101
106
|
e.preventDefault();
|
|
102
107
|
stepFrames(e.shiftKey ? -10 : -1);
|