@hyperframes/studio 0.7.57 → 0.7.59
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 → hyperframes-player-Bm07FLkl.js} +1 -1
- package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
- package/dist/assets/index-CrkAdJkb.js +426 -0
- package/dist/assets/index-Dj5p8U_A.css +1 -0
- package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +18029 -11563
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +6 -6
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/StudioRightPanel.tsx +29 -51
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +55 -1
- package/src/hooks/timelineTrackVisibility.ts +31 -13
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +81 -162
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +182 -84
- package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
- package/src/hooks/useFileManager.ts +82 -60
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +306 -155
- package/src/hooks/useTimelineEditing.ts +111 -101
- package/src/hooks/useTimelineEditingTypes.ts +4 -1
- package/src/hooks/useTimelineGroupEditing.ts +51 -5
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +490 -57
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +292 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +30 -27
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.ts +17 -0
- package/dist/assets/index-DfmYkU44.js +0 -423
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import type { TimelineTheme } from "./timelineTheme";
|
|
3
|
-
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
|
3
|
+
import { GUTTER, RULER_H, TRACKS_LEFT_PAD, formatTimelineTickLabel } from "./timelineLayout";
|
|
4
4
|
import { usePlayerStore } from "../store/playerStore";
|
|
5
5
|
import { secondsToFrame } from "../lib/time";
|
|
6
6
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
@@ -45,7 +45,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
45
45
|
the ruler's own small ticks mark intervals (no full-height lines). */}
|
|
46
46
|
<svg
|
|
47
47
|
className="absolute pointer-events-none"
|
|
48
|
-
style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
|
|
48
|
+
style={{ left: GUTTER + TRACKS_LEFT_PAD, width: trackContentWidth, zIndex: 0 }}
|
|
49
49
|
height={totalH}
|
|
50
50
|
>
|
|
51
51
|
{showBeats &&
|
|
@@ -74,17 +74,30 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
74
74
|
rows and drag overlays but below the playhead (z 100). */}
|
|
75
75
|
<div
|
|
76
76
|
className="sticky top-0 flex"
|
|
77
|
-
style={{
|
|
77
|
+
style={{
|
|
78
|
+
height: RULER_H,
|
|
79
|
+
width: GUTTER + TRACKS_LEFT_PAD + trackContentWidth,
|
|
80
|
+
zIndex: 70,
|
|
81
|
+
}}
|
|
78
82
|
>
|
|
79
83
|
<div
|
|
80
84
|
className="sticky left-0 z-[12] flex-shrink-0"
|
|
81
85
|
style={{
|
|
82
86
|
width: GUTTER,
|
|
83
|
-
// Ruler corner uses the panel surface — same as the ruler strip
|
|
87
|
+
// Ruler corner uses the panel surface — same as the ruler strip
|
|
88
|
+
// itself, and NO right border: the ruler band stays completely
|
|
89
|
+
// clean until 00:00 (the header-boundary line belongs to the track
|
|
90
|
+
// rows below, not the ruler).
|
|
84
91
|
background: theme.shellBackground,
|
|
85
|
-
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
86
92
|
}}
|
|
87
93
|
/>
|
|
94
|
+
{/* Left breathing pad — scrolls with the content, so 00:00 starts a
|
|
95
|
+
beat right of the gutter (see TRACKS_LEFT_PAD). */}
|
|
96
|
+
<div
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
className="flex-shrink-0"
|
|
99
|
+
style={{ width: TRACKS_LEFT_PAD, background: theme.shellBackground }}
|
|
100
|
+
/>
|
|
88
101
|
<div
|
|
89
102
|
className="relative overflow-hidden"
|
|
90
103
|
style={{
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
4
|
+
|
|
5
|
+
interface TrackGapContextMenuProps {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
/** Width (seconds) of the gap under the pointer, or null when no clip exists to the right. */
|
|
9
|
+
gapWidth: number | null;
|
|
10
|
+
/** "Close gap" actionable: a gap exists AND every clip that must shift is movable. */
|
|
11
|
+
canCloseGap: boolean;
|
|
12
|
+
/** "Close all gaps" actionable: the lane has gaps AND every shifting clip is movable. */
|
|
13
|
+
canCloseAllGaps: boolean;
|
|
14
|
+
/** The lane has at least one gap (distinguishes the two disabled reasons). */
|
|
15
|
+
hasAnyGaps: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
onCloseGap: () => void;
|
|
18
|
+
onCloseAllGaps: () => void;
|
|
19
|
+
/** Hover state for the gap-strip highlight overlay (null = nothing hovered).
|
|
20
|
+
* Only reported for ACTIONABLE rows — a disabled row closes nothing, so
|
|
21
|
+
* highlighting from it would promise an action that can't happen. */
|
|
22
|
+
onHoverAction: (action: "close-gap" | "close-all" | null) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Context menu for right-clicking EMPTY space on a timeline lane
|
|
27
|
+
* (CapCut/Premiere-style). Offers "Close gap" (collapse the clicked gap by
|
|
28
|
+
* shifting the following clips on that lane left) and "Close all gaps"
|
|
29
|
+
* (compact the whole lane contiguous from 0). Both rows are ALWAYS present —
|
|
30
|
+
* an inapplicable action dims with a tooltip explaining why, rather than
|
|
31
|
+
* vanishing into a one-item menu. Hovering an actionable row highlights the
|
|
32
|
+
* gap strip(s) it would close (via onHoverAction → TimelineCanvas overlay).
|
|
33
|
+
* Styling mirrors ClipContextMenu.
|
|
34
|
+
*/
|
|
35
|
+
export const TrackGapContextMenu = memo(function TrackGapContextMenu({
|
|
36
|
+
x,
|
|
37
|
+
y,
|
|
38
|
+
gapWidth,
|
|
39
|
+
canCloseGap,
|
|
40
|
+
canCloseAllGaps,
|
|
41
|
+
hasAnyGaps,
|
|
42
|
+
onClose,
|
|
43
|
+
onCloseGap,
|
|
44
|
+
onCloseAllGaps,
|
|
45
|
+
onHoverAction,
|
|
46
|
+
}: TrackGapContextMenuProps) {
|
|
47
|
+
const menuRef = useContextMenuDismiss(onClose);
|
|
48
|
+
|
|
49
|
+
const menuWidth = 200;
|
|
50
|
+
const menuHeight = 68;
|
|
51
|
+
const overflowY = y + menuHeight - window.innerHeight;
|
|
52
|
+
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
53
|
+
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
54
|
+
|
|
55
|
+
const itemClass = (enabled: boolean) =>
|
|
56
|
+
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
|
|
57
|
+
enabled
|
|
58
|
+
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
59
|
+
: "text-neutral-600 cursor-not-allowed"
|
|
60
|
+
}`;
|
|
61
|
+
|
|
62
|
+
// Disabled reasons: no gap under the pointer beats the lock reason — a
|
|
63
|
+
// pointer not on a gap has nothing to close regardless of movability.
|
|
64
|
+
const closeGapTitle = canCloseGap
|
|
65
|
+
? undefined
|
|
66
|
+
: gapWidth == null
|
|
67
|
+
? "No gap here"
|
|
68
|
+
: "A clip on this track can't be moved";
|
|
69
|
+
const closeAllTitle = canCloseAllGaps
|
|
70
|
+
? undefined
|
|
71
|
+
: hasAnyGaps
|
|
72
|
+
? "A clip on this track can't be moved"
|
|
73
|
+
: "No gaps on this track";
|
|
74
|
+
|
|
75
|
+
return createPortal(
|
|
76
|
+
<div
|
|
77
|
+
ref={menuRef}
|
|
78
|
+
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
79
|
+
style={{ left: adjustedX, top: adjustedY }}
|
|
80
|
+
onPointerLeave={() => onHoverAction(null)}
|
|
81
|
+
>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className={itemClass(canCloseGap)}
|
|
85
|
+
disabled={!canCloseGap}
|
|
86
|
+
title={closeGapTitle}
|
|
87
|
+
onPointerEnter={() => onHoverAction(canCloseGap ? "close-gap" : null)}
|
|
88
|
+
onClick={() => {
|
|
89
|
+
if (!canCloseGap) return;
|
|
90
|
+
onCloseGap();
|
|
91
|
+
onClose();
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<span>Close gap</span>
|
|
95
|
+
{gapWidth != null && (
|
|
96
|
+
<span className="text-neutral-500 text-[10px] ml-3">{gapWidth.toFixed(2)}s</span>
|
|
97
|
+
)}
|
|
98
|
+
</button>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
className={itemClass(canCloseAllGaps)}
|
|
102
|
+
disabled={!canCloseAllGaps}
|
|
103
|
+
title={closeAllTitle}
|
|
104
|
+
onPointerEnter={() => onHoverAction(canCloseAllGaps ? "close-all" : null)}
|
|
105
|
+
onClick={() => {
|
|
106
|
+
if (!canCloseAllGaps) return;
|
|
107
|
+
onCloseAllGaps();
|
|
108
|
+
onClose();
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<span>Close all gaps</span>
|
|
112
|
+
</button>
|
|
113
|
+
</div>,
|
|
114
|
+
document.body,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
@@ -31,11 +31,14 @@ export interface TimelineEditCallbacks {
|
|
|
31
31
|
) => Promise<void> | void;
|
|
32
32
|
/** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
|
|
33
33
|
* `coalesceKey` (drag-commit gesture id) merges the move history entry with a
|
|
34
|
-
* lane change's follow-up z-reorder entry into one undo step
|
|
34
|
+
* lane change's follow-up z-reorder entry into one undo step; `coalesceMs`
|
|
35
|
+
* widens that entry's fold window when a server round-trip separates the
|
|
36
|
+
* gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
|
|
35
37
|
onMoveElements?: (
|
|
36
38
|
edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
|
|
37
39
|
coalesceKey?: string,
|
|
38
40
|
operation?: TimelineMoveOperation,
|
|
41
|
+
coalesceMs?: number,
|
|
39
42
|
) => Promise<void> | void;
|
|
40
43
|
onResizeElement?: (
|
|
41
44
|
element: TimelineElement,
|
|
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
3
3
|
import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
4
4
|
import {
|
|
5
5
|
commitDraggedClipMove,
|
|
6
|
+
commitZMirrorLaneMove,
|
|
6
7
|
type DragCommitDeps,
|
|
7
8
|
type TimelineMoveEdit,
|
|
8
9
|
} from "./timelineClipDragCommit";
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
pushEditHistoryEntry,
|
|
13
14
|
} from "../../utils/editHistory";
|
|
14
15
|
import { normalizeToZones } from "./timelineZones";
|
|
16
|
+
import { resolveZMirrorLaneMove } from "./timelineZMirror";
|
|
15
17
|
import type { StackingPatch } from "./timelineStackingSync";
|
|
16
18
|
|
|
17
19
|
function el(
|
|
@@ -29,9 +31,7 @@ function el(
|
|
|
29
31
|
/** Flush the microtask chain: the z-sync now fires only after the move persist
|
|
30
32
|
* promise resolves (serialized), so tests asserting on it must await. */
|
|
31
33
|
async function flushMicrotasks(): Promise<void> {
|
|
32
|
-
await Promise.resolve();
|
|
33
|
-
await Promise.resolve();
|
|
34
|
-
await Promise.resolve();
|
|
34
|
+
for (let step = 0; step < 8; step += 1) await Promise.resolve();
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function drag(
|
|
@@ -370,6 +370,73 @@ describe("commitDraggedClipMove", () => {
|
|
|
370
370
|
expect(map.b.track).toBe(2); // at/below the insert → +1 shift
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
+
it("a selected audio passenger moves in time during a visual insert without being renumbered", () => {
|
|
374
|
+
const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
|
|
375
|
+
const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
|
|
376
|
+
const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
|
|
377
|
+
const audio = {
|
|
378
|
+
...el("audio", 2, 4, 20, "audio"),
|
|
379
|
+
sourceFile: "scene.html",
|
|
380
|
+
authoredTrack: 7,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const { onMoveElements } = runClipMove(
|
|
384
|
+
drag(t, { previewStart: 5, previewTrack: 1, insertRow: 1 }),
|
|
385
|
+
{
|
|
386
|
+
elements: [a, b, t, audio],
|
|
387
|
+
trackOrder: [0, 1, 2],
|
|
388
|
+
selectedKeys: new Set(["t", "audio"]),
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
393
|
+
expect(map.audio).toEqual({ start: 9, track: 7 });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("an audio insert persists zone-local tracks without rewriting visual lanes", () => {
|
|
397
|
+
const v0 = el("v0", 0, 0, 5);
|
|
398
|
+
const v1 = el("v1", 1, 0, 5);
|
|
399
|
+
const a = { ...el("a", 2, 0, 5, "audio"), authoredTrack: 0 };
|
|
400
|
+
const b = { ...el("b", 3, 10, 5, "audio"), authoredTrack: 1 };
|
|
401
|
+
const t = { ...el("t", 3, 0, 5, "audio"), authoredTrack: 1 };
|
|
402
|
+
|
|
403
|
+
const { onMoveElements } = runClipMove(
|
|
404
|
+
drag(t, { previewStart: 0, previewTrack: 3, insertRow: 3 }),
|
|
405
|
+
{ elements: [v0, v1, a, b, t], trackOrder: [0, 1, 2, 3] },
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
|
|
409
|
+
a: { start: 0, track: 0 },
|
|
410
|
+
t: { start: 0, track: 1 },
|
|
411
|
+
b: { start: 10, track: 2 },
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("refuses an audio insert when it would renumber a locked authored row", () => {
|
|
416
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
417
|
+
const visual = el("visual", 0, 0, 5);
|
|
418
|
+
const locked = {
|
|
419
|
+
...el("locked", 1, 0, 5, "audio"),
|
|
420
|
+
authoredTrack: 0,
|
|
421
|
+
timelineLocked: true,
|
|
422
|
+
};
|
|
423
|
+
const target = { ...el("target", 2, 0, 5, "audio"), authoredTrack: 1 };
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const { onMoveElements } = runClipMove(
|
|
427
|
+
drag(target, { previewStart: 0, previewTrack: 2, insertRow: 1 }),
|
|
428
|
+
{ elements: [visual, locked, target], trackOrder: [0, 1, 2] },
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
432
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
433
|
+
expect.stringContaining("locked clip locked would need renumbering"),
|
|
434
|
+
);
|
|
435
|
+
} finally {
|
|
436
|
+
warnSpy.mockRestore();
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
373
440
|
describe("lane ↔ stacking sync", () => {
|
|
374
441
|
it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", async () => {
|
|
375
442
|
// a & b overlap in time. Elements carry their authored z (as real discovery
|
|
@@ -1024,3 +1091,212 @@ describe("commitDraggedClipMove", () => {
|
|
|
1024
1091
|
});
|
|
1025
1092
|
});
|
|
1026
1093
|
});
|
|
1094
|
+
|
|
1095
|
+
describe("commitZMirrorLaneMove", () => {
|
|
1096
|
+
const mirrorDeps = (elements: TimelineElement[], trackOrder: number[]) => {
|
|
1097
|
+
const updateElement = vi.fn();
|
|
1098
|
+
const onMoveElements = vi.fn();
|
|
1099
|
+
return {
|
|
1100
|
+
updateElement,
|
|
1101
|
+
onMoveElements,
|
|
1102
|
+
deps: { elements, trackOrder, updateElement, onMoveElements } as DragCommitDeps,
|
|
1103
|
+
};
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
it("kind:move persists start + display lane with the persistTrack override (same shape as a lane drag)", async () => {
|
|
1107
|
+
// t sits at lane 2 in a sparse file (authored 7); the mirror lands it on
|
|
1108
|
+
// lane 0 whose authored track is 3.
|
|
1109
|
+
const t = { ...el("t", 2, 0, 10), authoredTrack: 7 };
|
|
1110
|
+
const elements = [{ ...el("a", 0, 20, 5), authoredTrack: 3 }, el("b", 1, 0, 10), t];
|
|
1111
|
+
const { updateElement, onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
|
|
1112
|
+
const moved = await commitZMirrorLaneMove(
|
|
1113
|
+
t,
|
|
1114
|
+
{ kind: "move", displayTrack: 0, persistTrack: 3 },
|
|
1115
|
+
deps,
|
|
1116
|
+
"z-reorder:bring-forward:t",
|
|
1117
|
+
);
|
|
1118
|
+
expect(moved).toBe(true);
|
|
1119
|
+
// Optimistic store update: DISPLAY lane + the written authoredTrack mirror.
|
|
1120
|
+
expect(updateElement).toHaveBeenCalledWith("t", {
|
|
1121
|
+
start: 0,
|
|
1122
|
+
track: 0,
|
|
1123
|
+
authoredTrack: 3,
|
|
1124
|
+
});
|
|
1125
|
+
// Persist: authored-space track, the z persist's coalesce key, lane-reorder op.
|
|
1126
|
+
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
|
1127
|
+
const [persistEdits, coalesceKey, operation] = onMoveElements.mock.calls[0];
|
|
1128
|
+
expect(editMap(persistEdits)).toEqual({ t: { start: 0, track: 3 } });
|
|
1129
|
+
expect(coalesceKey).toBe("z-reorder:bring-forward:t");
|
|
1130
|
+
expect(operation).toBe("lane-reorder");
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it("kind:insert reuses the track-insert renumber core (+1 shift below the new lane)", async () => {
|
|
1134
|
+
// a,b,c mutually overlapping on lanes 0/1/2. Mirror-insert c at row 1: c
|
|
1135
|
+
// lands on the new lane, b (at/below) shifts down — identical to the drag
|
|
1136
|
+
// insert test above, proving the shared core (no duplicated renumber logic).
|
|
1137
|
+
const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)];
|
|
1138
|
+
const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
|
|
1139
|
+
const moved = await commitZMirrorLaneMove(
|
|
1140
|
+
elements[2],
|
|
1141
|
+
{ kind: "insert", insertRow: 1 },
|
|
1142
|
+
deps,
|
|
1143
|
+
"z-reorder:bring-forward:c",
|
|
1144
|
+
);
|
|
1145
|
+
expect(moved).toBe(true);
|
|
1146
|
+
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
|
1147
|
+
expect(onMoveElements.mock.calls[0][1]).toBe("z-reorder:bring-forward:c");
|
|
1148
|
+
expect(onMoveElements.mock.calls[0][2]).toBe("track-insert");
|
|
1149
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1150
|
+
expect(map.a.track).toBe(0);
|
|
1151
|
+
expect(map.c.track).toBe(1);
|
|
1152
|
+
expect(map.b.track).toBe(2);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it("visual mirror inserts never persist or renumber same-file audio", async () => {
|
|
1156
|
+
// b and t share a visual lane because they do not overlap. Inserting t between
|
|
1157
|
+
// a and b creates one extra visual lane, so whole-timeline normalization moves
|
|
1158
|
+
// audio from display lane 2 to 3. That display-only shift must not be written.
|
|
1159
|
+
const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
|
|
1160
|
+
const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
|
|
1161
|
+
const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
|
|
1162
|
+
const audio = { ...el("audio", 2, 0, 20, "audio"), sourceFile: "scene.html" };
|
|
1163
|
+
const { onMoveElements, deps } = mirrorDeps([a, b, t, audio], [0, 1, 2]);
|
|
1164
|
+
|
|
1165
|
+
const moved = await commitZMirrorLaneMove(
|
|
1166
|
+
t,
|
|
1167
|
+
{ kind: "insert", insertRow: 1 },
|
|
1168
|
+
deps,
|
|
1169
|
+
"z-reorder:bring-forward:t",
|
|
1170
|
+
);
|
|
1171
|
+
|
|
1172
|
+
expect(moved).toBe(true);
|
|
1173
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1174
|
+
expect(map.audio).toBeUndefined();
|
|
1175
|
+
expect(map).toEqual({
|
|
1176
|
+
a: { start: 0, track: 0 },
|
|
1177
|
+
b: { start: 10, track: 2 },
|
|
1178
|
+
t: { start: 0, track: 1 },
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it("foreign expanded rows never distort the root-file insert topology", async () => {
|
|
1183
|
+
const host = { ...el("host", 0, 0, 5), sourceFile: "index.html" };
|
|
1184
|
+
const child1 = {
|
|
1185
|
+
...el("child-1", 0.25, 0, 5),
|
|
1186
|
+
sourceFile: "scene.html",
|
|
1187
|
+
expandedParentStart: 0,
|
|
1188
|
+
};
|
|
1189
|
+
const child2 = {
|
|
1190
|
+
...el("child-2", 0.5, 0, 5),
|
|
1191
|
+
sourceFile: "scene.html",
|
|
1192
|
+
expandedParentStart: 0,
|
|
1193
|
+
};
|
|
1194
|
+
const b = { ...el("b", 1, 0, 5), sourceFile: "index.html" };
|
|
1195
|
+
const t = { ...el("t", 2, 0, 5), sourceFile: "index.html" };
|
|
1196
|
+
const elements = [host, child1, child2, b, t];
|
|
1197
|
+
const { onMoveElements, deps } = mirrorDeps(elements, [0, 0.25, 0.5, 1, 2]);
|
|
1198
|
+
|
|
1199
|
+
const moved = await commitZMirrorLaneMove(
|
|
1200
|
+
t,
|
|
1201
|
+
{ kind: "insert", insertRow: 1 },
|
|
1202
|
+
deps,
|
|
1203
|
+
"z-reorder:bring-forward:t",
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
expect(moved).toBe(true);
|
|
1207
|
+
expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
|
|
1208
|
+
host: { start: 0, track: 0 },
|
|
1209
|
+
t: { start: 0, track: 1 },
|
|
1210
|
+
b: { start: 0, track: 2 },
|
|
1211
|
+
});
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
it("never triggers the lane→z stacking sync (it would fight the just-set z values)", async () => {
|
|
1215
|
+
// Even with BOTH z-sync deps supplied (the drag paths would engage them for
|
|
1216
|
+
// a vertical move like this), the mirror commit must not emit stacking
|
|
1217
|
+
// patches — the z values were just written by the user's menu action.
|
|
1218
|
+
const t = el("t", 2, 0, 10);
|
|
1219
|
+
const elements = [el("a", 0, 20, 5), el("b", 1, 0, 10), t];
|
|
1220
|
+
const { deps } = mirrorDeps(elements, [0, 1, 2]);
|
|
1221
|
+
const onStackingPatches = vi.fn();
|
|
1222
|
+
const moved = await commitZMirrorLaneMove(
|
|
1223
|
+
t,
|
|
1224
|
+
{ kind: "move", displayTrack: 0, persistTrack: 0 },
|
|
1225
|
+
{ ...deps, readZIndex: () => 0, onStackingPatches },
|
|
1226
|
+
"z-reorder:bring-forward:t",
|
|
1227
|
+
);
|
|
1228
|
+
await flushMicrotasks();
|
|
1229
|
+
expect(moved).toBe(true);
|
|
1230
|
+
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it("resolves false and rolls the store back when the persist rejects", async () => {
|
|
1234
|
+
const t = el("t", 1, 0, 10);
|
|
1235
|
+
const elements = [el("a", 0, 20, 5), t];
|
|
1236
|
+
const updateElement = vi.fn();
|
|
1237
|
+
const onMoveElements = vi.fn().mockRejectedValue(new Error("boom"));
|
|
1238
|
+
const moved = await commitZMirrorLaneMove(
|
|
1239
|
+
t,
|
|
1240
|
+
{ kind: "move", displayTrack: 0, persistTrack: 0 },
|
|
1241
|
+
{ elements, trackOrder: [0, 1], updateElement, onMoveElements },
|
|
1242
|
+
"z-reorder:send-backward:t",
|
|
1243
|
+
);
|
|
1244
|
+
expect(moved).toBe(false);
|
|
1245
|
+
// Optimistic write then rollback to the original lane.
|
|
1246
|
+
expect(updateElement).toHaveBeenLastCalledWith("t", {
|
|
1247
|
+
start: 0,
|
|
1248
|
+
track: 1,
|
|
1249
|
+
authoredTrack: undefined,
|
|
1250
|
+
});
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
it("END-TO-END one-element step: resolver insertRow renumbers the clip strictly between the two neighbors", async () => {
|
|
1254
|
+
// 3 stacked back-to-back clips + a free lane beyond the far one. Send t
|
|
1255
|
+
// (top) backward past b: the resolver must bound at c and produce the
|
|
1256
|
+
// insert row IMMEDIATELY below b, and commitZMirrorLaneMove's renumber must
|
|
1257
|
+
// land t strictly between b and c — never on the farther free lane 3.
|
|
1258
|
+
const t = el("t", 0, 0, 10);
|
|
1259
|
+
const b = el("b", 1, 0, 10);
|
|
1260
|
+
const c = el("c", 2, 0, 10);
|
|
1261
|
+
const far = el("far", 3, 20, 5); // free over t's span, beyond c
|
|
1262
|
+
const elements = [t, b, c, far];
|
|
1263
|
+
const move = resolveZMirrorLaneMove({
|
|
1264
|
+
action: "send-backward",
|
|
1265
|
+
element: t,
|
|
1266
|
+
elements,
|
|
1267
|
+
crossedKey: "b",
|
|
1268
|
+
});
|
|
1269
|
+
expect(move).toEqual({ kind: "insert", insertRow: 2 });
|
|
1270
|
+
const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2, 3]);
|
|
1271
|
+
const moved = await commitZMirrorLaneMove(t, move!, deps, "z-reorder:send-backward:t");
|
|
1272
|
+
expect(moved).toBe(true);
|
|
1273
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1274
|
+
// The renumber compacts t's vacated top lane, so the whole set shifts up by
|
|
1275
|
+
// one while t lands on the b/c boundary — strictly between the two.
|
|
1276
|
+
expect(map).toEqual({
|
|
1277
|
+
b: { start: 0, track: 0 },
|
|
1278
|
+
t: { start: 0, track: 1 },
|
|
1279
|
+
c: { start: 0, track: 2 },
|
|
1280
|
+
far: { start: 20, track: 3 },
|
|
1281
|
+
});
|
|
1282
|
+
expect(map.b.track).toBeLessThan(map.t.track);
|
|
1283
|
+
expect(map.t.track).toBeLessThan(map.c.track);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
it("resolves false for a refused insert (locked clip would need renumbering)", async () => {
|
|
1287
|
+
// b is locked and sits at/below the insert row, so the whole-set renumber
|
|
1288
|
+
// is refused — no persist call.
|
|
1289
|
+
const a = el("a", 0, 0, 5);
|
|
1290
|
+
const b: TimelineElement = { ...el("b", 1, 0, 5), timelineLocked: true };
|
|
1291
|
+
const c = el("c", 2, 0, 5);
|
|
1292
|
+
const { onMoveElements, deps } = mirrorDeps([a, b, c], [0, 1, 2]);
|
|
1293
|
+
const moved = await commitZMirrorLaneMove(
|
|
1294
|
+
c,
|
|
1295
|
+
{ kind: "insert", insertRow: 1 },
|
|
1296
|
+
deps,
|
|
1297
|
+
"z-reorder:bring-forward:c",
|
|
1298
|
+
);
|
|
1299
|
+
expect(moved).toBe(false);
|
|
1300
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
1301
|
+
});
|
|
1302
|
+
});
|