@hyperframes/studio 0.8.50 → 0.8.53-desktop.0
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-CxxDxHTW.js → hyperframes-player-PTEnAHhg.js} +3 -3
- package/dist/assets/{index-DUAIWxkI.js → index--myXp8ui.js} +1 -1
- package/dist/assets/{index-Dt3qBI-9.js → index-BjslOS9g.js} +1 -1
- package/dist/assets/index-BnXZuIJD.css +1 -0
- package/dist/assets/index-CNyviK_l.js +390 -0
- package/dist/index.css +29 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +825 -24
- package/dist/index.html +2 -2
- package/dist/index.js +8911 -9853
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +13 -12
- package/src/App.tsx +145 -163
- package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
- package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
- package/src/captions/components/CaptionOverlay.tsx +8 -4
- package/src/captions/components/CaptionOverlayUtils.ts +2 -3
- package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
- package/src/captions/components/CaptionTimeline.tsx +1 -1
- package/src/captions/components/shared.tsx +2 -2
- package/src/components/AskAgentModal.tsx +5 -5
- package/src/components/EditorShell.selectionSync.test.tsx +9 -4
- package/src/components/EditorShell.tsx +59 -62
- package/src/components/ExternalFileConflictBanner.tsx +16 -16
- package/src/components/LintModal.tsx +6 -16
- package/src/components/MediaPreview.tsx +2 -2
- package/src/components/RightPanelTabs.test.tsx +151 -0
- package/src/components/RightPanelTabs.tsx +59 -0
- package/src/components/SaveQueuePausedBanner.tsx +2 -2
- package/src/components/StudioHeader.dom.test.tsx +181 -0
- package/src/components/StudioHeader.test.ts +0 -12
- package/src/components/StudioHeader.tsx +118 -221
- package/src/components/StudioLeftPanels.tsx +202 -0
- package/src/components/StudioOverlays.tsx +1 -1
- package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
- package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
- package/src/components/StudioToast.tsx +2 -2
- package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
- package/src/components/TimelineHistoryButtons.tsx +54 -0
- package/src/components/TimelineToolbar.test.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +8 -12
- package/src/components/dock/Dock.test.tsx +274 -0
- package/src/components/dock/Dock.tsx +291 -0
- package/src/components/dock/dock.css +28 -0
- package/src/components/dock/dockAccessibility.test.ts +166 -0
- package/src/components/dock/dockAccessibility.ts +98 -0
- package/src/components/dock/dockLayout.test.ts +134 -0
- package/src/components/dock/dockLayout.ts +105 -0
- package/src/components/dock/dockLayoutSchema.ts +66 -0
- package/src/components/dock/dockLayoutStore.test.ts +28 -0
- package/src/components/dock/dockLayoutStore.ts +108 -0
- package/src/components/dock/dockTestHarness.ts +11 -0
- package/src/components/dock/panelRegistry.ts +67 -0
- package/src/components/editor/AnimationCard.tsx +4 -4
- package/src/components/editor/AnimationCardParts.tsx +6 -6
- package/src/components/editor/BlockParamsPanel.tsx +4 -4
- package/src/components/editor/BorderRadiusEditor.tsx +1 -1
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ComputedTweenNotice.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -1
- package/src/components/editor/EaseCurveSection.tsx +1 -1
- package/src/components/editor/EaseParamFields.tsx +5 -5
- package/src/components/editor/FileTree.tsx +4 -4
- package/src/components/editor/FileTreeIcons.tsx +1 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -13
- package/src/components/editor/InlineTextToolbar.tsx +1 -1
- package/src/components/editor/InspectorHeaderActions.tsx +3 -3
- package/src/components/editor/KeyframeDiamond.tsx +1 -1
- package/src/components/editor/KeyframeEaseList.tsx +1 -1
- package/src/components/editor/LayersPanel.test.ts +97 -1
- package/src/components/editor/LayersPanel.tsx +7 -5
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/OffCanvasIndicators.tsx +1 -1
- package/src/components/editor/PreviewGuides.tsx +1 -1
- package/src/components/editor/PromotableControl.tsx +2 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
- package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
- package/src/components/editor/SnapToolbar.test.tsx +0 -3
- package/src/components/editor/SnapToolbar.tsx +2 -2
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/TopologyLens.test.tsx +28 -0
- package/src/components/editor/TopologyLens.tsx +7 -5
- package/src/components/editor/Transform3DCube.tsx +2 -2
- package/src/components/editor/audioFxRevealTarget.ts +1 -0
- package/src/components/editor/domEditing.test.ts +1 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
- package/src/components/editor/domEditingAgentPrompt.ts +4 -3
- package/src/components/editor/propertyPanelColor.tsx +11 -11
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
- package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
- package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFill.tsx +4 -4
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
- package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
- package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
- package/src/components/editor/propertyPanelFont.tsx +5 -5
- package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.tsx +6 -6
- package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
- package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
- package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
- package/src/components/editor/propertyPanelSections.tsx +8 -8
- package/src/components/editor/useColorGradingController.test.ts +38 -0
- package/src/components/editor/useColorGradingController.ts +9 -1
- package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
- package/src/components/editor/useColorGradingPreviews.ts +11 -1
- package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
- package/src/components/editor/usePreviewCompositionRect.ts +21 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
- package/src/components/feedback/feedbackTrigger.ts +0 -1
- package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
- package/src/components/nle/NLEContext.tsx +0 -43
- package/src/components/nle/NLEPreview.tsx +3 -3
- package/src/components/nle/PreviewOverlays.tsx +6 -6
- package/src/components/nle/PreviewPane.tsx +3 -3
- package/src/components/nle/TimelinePane.tsx +53 -67
- package/src/components/panels/SlideshowPanel.tsx +3 -3
- package/src/components/panels/SlideshowSubPanels.tsx +20 -17
- package/src/components/panels/VariablesBindElement.tsx +2 -2
- package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
- package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +4 -4
- package/src/components/panels/VariablesValueControls.tsx +2 -2
- package/src/components/pointerTargetSize.test.tsx +8 -0
- package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
- package/src/components/renders/RenderQueue.test.tsx +7 -0
- package/src/components/renders/RenderQueue.tsx +7 -7
- package/src/components/renders/RenderQueueItem.tsx +6 -6
- package/src/components/renders/RenderQueuePanel.tsx +1 -1
- package/src/components/sidebar/AssetCard.tsx +8 -8
- package/src/components/sidebar/AssetContextMenu.tsx +8 -8
- package/src/components/sidebar/AssetsTab.tsx +2 -2
- package/src/components/sidebar/AudioRow.tsx +6 -6
- package/src/components/sidebar/BlocksTab.tsx +6 -6
- package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
- package/src/components/sidebar/CompositionsPanel.tsx +15 -0
- package/src/components/sidebar/CompositionsTab.tsx +5 -5
- package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
- package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
- package/src/components/sidebar/SidebarLintButton.test.tsx +53 -0
- package/src/components/sidebar/SidebarLintButton.tsx +50 -0
- package/src/components/timelineToolbarStyles.ts +5 -0
- package/src/components/ui/Button.test.tsx +252 -0
- package/src/components/ui/Button.tsx +89 -126
- package/src/components/ui/IconButton.tsx +38 -0
- package/src/components/ui/Input.tsx +115 -0
- package/src/components/ui/Menu.test.tsx +374 -0
- package/src/components/ui/Menu.tsx +213 -0
- package/src/components/ui/NumberField.tsx +122 -0
- package/src/components/ui/Popover.tsx +74 -0
- package/src/components/ui/SearchInput.tsx +2 -2
- package/src/components/ui/Select.tsx +100 -0
- package/src/components/ui/Slider.tsx +153 -0
- package/src/components/ui/Tabs.test.tsx +98 -0
- package/src/components/ui/Tabs.tsx +71 -0
- package/src/components/ui/Toggle.tsx +63 -0
- package/src/components/ui/Tooltip.test.tsx +110 -0
- package/src/components/ui/Tooltip.tsx +38 -112
- package/src/components/ui/cn.test.ts +30 -0
- package/src/components/ui/cn.ts +25 -0
- package/src/components/ui/index.ts +29 -2
- package/src/components/ui/mountHost.testHelpers.ts +24 -0
- package/src/components/ui/valueControls.test.tsx +524 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -57
- package/src/hooks/appHotkeysDispatch.ts +3 -4
- package/src/hooks/timelineEditPermission.ts +31 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
- package/src/hooks/useAppHotkeys.test.ts +0 -1
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
- package/src/hooks/useAppHotkeys.ts +21 -84
- package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useDomEditPreviewSync.ts +4 -6
- package/src/hooks/useDomEditSession.ts +0 -7
- package/src/hooks/useDomEditWiring.ts +4 -9
- package/src/hooks/useEditHistoryActions.test.tsx +90 -0
- package/src/hooks/useEditHistoryActions.ts +106 -0
- package/src/hooks/useFileManager.ts +21 -123
- package/src/hooks/useLintModal.test.tsx +65 -0
- package/src/hooks/useLintModal.ts +3 -0
- package/src/hooks/usePanelLayout.test.ts +108 -296
- package/src/hooks/usePanelLayout.ts +50 -255
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useProjectFileWriter.ts +150 -0
- package/src/hooks/useRightPanelIntents.test.ts +181 -0
- package/src/hooks/useRightPanelIntents.ts +63 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
- package/src/hooks/useSdkSession.ts +43 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
- package/src/hooks/useServerConnection.ts +43 -10
- package/src/hooks/useSlideshowTabState.test.ts +9 -31
- package/src/hooks/useSlideshowTabState.ts +6 -19
- package/src/hooks/useStudioContextValue.test.ts +6 -18
- package/src/hooks/useStudioContextValue.ts +5 -26
- package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
- package/src/hooks/useTimelineAssetDropOps.ts +52 -11
- package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
- package/src/hooks/useTimelineEditing.test.tsx +322 -3
- package/src/hooks/useTimelineEditing.ts +103 -16
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
- package/src/hostMount.test.tsx +222 -0
- package/src/index.ts +87 -0
- package/src/main.tsx +9 -3
- package/src/player/components/AudioWaveform.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +3 -3
- package/src/player/components/ClipContextMenu.tsx +3 -3
- package/src/player/components/CompositionThumbnail.test.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +3 -4
- package/src/player/components/EditModal.tsx +1 -1
- package/src/player/components/ImageThumbnail.tsx +1 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
- package/src/player/components/LayerDisclosureRow.tsx +2 -2
- package/src/player/components/PlayerControls.tsx +1 -1
- package/src/player/components/ShortcutsPanel.tsx +9 -9
- package/src/player/components/SpeedMenu.tsx +3 -5
- package/src/player/components/Timeline.tsx +3 -2
- package/src/player/components/TimelineCanvas.tsx +21 -14
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
- package/src/player/components/TimelineEmptyState.tsx +5 -5
- package/src/player/components/TimelineFxButton.tsx +6 -6
- package/src/player/components/TimelineGroupHeader.tsx +5 -5
- package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +32 -0
- package/src/player/components/TimelineLanes.tsx +2 -5
- package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +1 -1
- package/src/player/components/TimelineShortcutHint.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +2 -2
- package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
- package/src/player/components/TimelineTypes.ts +2 -1
- package/src/player/components/TrackGapContextMenu.tsx +1 -1
- package/src/player/components/VideoThumbnail.tsx +4 -2
- package/src/player/components/VolumeControl.tsx +2 -2
- package/src/player/components/timelineCallbacks.ts +8 -8
- package/src/player/components/timelineClipDragPreview.ts +51 -27
- package/src/player/components/timelineClipDragTypes.ts +3 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -1
- package/src/player/components/timelineDragDrop.ts +57 -15
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineSnapping.test.ts +27 -0
- package/src/player/components/timelineSnapping.ts +14 -1
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +30 -30
- package/src/player/components/trackHeaderLabelRows.tsx +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
- package/src/player/components/useTimelineClipDrag.ts +30 -3
- package/src/player/lib/describeClips.ts +3 -54
- package/src/player/lib/playbackShortcuts.ts +2 -0
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
- package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
- package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
- package/src/player/store/previewIframeStore.test.tsx +29 -0
- package/src/player/store/previewIframeStore.ts +25 -0
- package/src/renderQueueExports.test.tsx +58 -0
- package/src/styles/classCandidates.test.ts +105 -0
- package/src/styles/classCandidates.ts +281 -0
- package/src/styles/hex-baseline.json +84 -0
- package/src/styles/hexRatchet.test.ts +201 -0
- package/src/styles/iconTokens.ts +20 -0
- package/src/styles/palette-reset.css +6 -0
- package/src/styles/studio.css +24 -21
- package/src/styles/styleSources.test.ts +20 -0
- package/src/styles/styleSources.ts +47 -0
- package/src/styles/tailwind-preset.shared.js +3 -0
- package/src/styles/theme.css +270 -0
- package/src/styles/theme.test.ts +183 -0
- package/src/styles/tokenGate.test.ts +109 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +11 -8
- package/src/utils/studioUiPreferences.test.ts +10 -16
- package/src/utils/studioUiPreferences.ts +7 -16
- package/src/utils/timelineDropTrackInsert.test.ts +134 -0
- package/src/utils/timelineDropTrackInsert.ts +83 -0
- package/src/utils/typingTarget.ts +4 -0
- package/dist/assets/index-n2ml4tUn.js +0 -442
- package/dist/assets/index-pO6p0H27.css +0 -1
- package/src/components/PanelTabButton.tsx +0 -31
- package/src/components/StudioLeftSidebar.tsx +0 -204
- package/src/components/nle/TimelineResizeDivider.tsx +0 -101
- package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
- package/src/components/sidebar/LeftSidebar.tsx +0 -334
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
- package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
- package/src/components/storyboard/FramePoster.test.tsx +0 -61
- package/src/components/storyboard/FramePoster.tsx +0 -86
- package/src/components/storyboard/StoryboardDirection.tsx +0 -45
- package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
- package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
- package/src/components/storyboard/StoryboardGrid.tsx +0 -55
- package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
- package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
- package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
- package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
- package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
- package/src/components/storyboard/StoryboardView.tsx +0 -169
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
- package/src/components/storyboard/frameComments.test.ts +0 -95
- package/src/components/storyboard/frameComments.ts +0 -115
- package/src/components/storyboard/frameStatus.ts +0 -36
- package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
- package/src/components/storyboard/storyboardReviewStage.ts +0 -45
- package/src/components/storyboard/useFrameComments.ts +0 -98
- package/src/contexts/ViewModeContext.tsx +0 -142
- package/src/hooks/useInspectorSplitResize.ts +0 -51
- package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
- package/src/hooks/useProjectSignaturePoll.ts +0 -68
- package/src/hooks/useStoryboard.ts +0 -99
- package/src/utils/fitPanels.test.ts +0 -164
- package/src/utils/fitPanels.ts +0 -151
|
@@ -122,12 +122,14 @@ function renderTimelineEditingHook(input: {
|
|
|
122
122
|
forceReloadSdkSession?: () => void;
|
|
123
123
|
invalidateGsapCache?: () => void;
|
|
124
124
|
showToast?: (message: string, kind?: string) => void;
|
|
125
|
+
canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
|
|
125
126
|
}): {
|
|
126
127
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
|
127
128
|
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
|
|
128
129
|
groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
|
|
129
130
|
groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
|
|
130
131
|
del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
|
|
132
|
+
elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"];
|
|
131
133
|
unmount: () => void;
|
|
132
134
|
} {
|
|
133
135
|
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
|
|
@@ -135,6 +137,8 @@ function renderTimelineEditingHook(input: {
|
|
|
135
137
|
let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
|
|
136
138
|
let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
|
|
137
139
|
let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
|
|
140
|
+
let elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"] | null =
|
|
141
|
+
null;
|
|
138
142
|
|
|
139
143
|
function Harness() {
|
|
140
144
|
const commitRef = useRef(input.onZIndexCommit);
|
|
@@ -155,12 +159,14 @@ function renderTimelineEditingHook(input: {
|
|
|
155
159
|
forceReloadSdkSession: input.forceReloadSdkSession,
|
|
156
160
|
invalidateGsapCache: input.invalidateGsapCache,
|
|
157
161
|
handleDomZIndexReorderCommitRef: commitRef,
|
|
162
|
+
canEdit: input.canEdit,
|
|
158
163
|
});
|
|
159
164
|
move = hook.handleTimelineElementMove;
|
|
160
165
|
resize = hook.handleTimelineElementResize;
|
|
161
166
|
groupMove = hook.handleTimelineGroupMove;
|
|
162
167
|
groupResize = hook.handleTimelineGroupResize;
|
|
163
168
|
del = hook.handleTimelineElementDelete;
|
|
169
|
+
elementsDelete = hook.handleTimelineElementsDelete;
|
|
164
170
|
return null;
|
|
165
171
|
}
|
|
166
172
|
|
|
@@ -170,7 +176,8 @@ function renderTimelineEditingHook(input: {
|
|
|
170
176
|
if (!groupMove) throw new Error("Expected hook to expose group move handler");
|
|
171
177
|
if (!groupResize) throw new Error("Expected hook to expose group resize handler");
|
|
172
178
|
if (!del) throw new Error("Expected hook to expose delete handler");
|
|
173
|
-
|
|
179
|
+
if (!elementsDelete) throw new Error("Expected hook to expose elements-delete handler");
|
|
180
|
+
return { move, resize, groupMove, groupResize, del, elementsDelete, unmount };
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
type TimelineRecordEdit = NonNullable<
|
|
@@ -289,6 +296,7 @@ function setupSingleClipHarness(options?: {
|
|
|
289
296
|
source?: string;
|
|
290
297
|
clipStyle?: string;
|
|
291
298
|
onZIndexCommit?: (entries: ZIndexEntry[]) => Promise<void>;
|
|
299
|
+
canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
|
|
292
300
|
}) {
|
|
293
301
|
const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]);
|
|
294
302
|
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
@@ -300,16 +308,30 @@ function setupSingleClipHarness(options?: {
|
|
|
300
308
|
const fetchMock = stubProjectFetch(
|
|
301
309
|
options?.source ?? '<div id="clip" data-start="0" data-track-index="0"></div>',
|
|
302
310
|
);
|
|
311
|
+
const recordEdit = vi.fn(async () => {});
|
|
312
|
+
const showToast = vi.fn();
|
|
303
313
|
const hook = renderTimelineEditingHook({
|
|
304
314
|
timelineElements: [clip],
|
|
305
315
|
iframe,
|
|
306
316
|
onZIndexCommit: commit,
|
|
307
317
|
projectId: "p1",
|
|
308
318
|
writeProjectFile,
|
|
309
|
-
recordEdit
|
|
319
|
+
recordEdit,
|
|
310
320
|
reloadPreview,
|
|
321
|
+
showToast,
|
|
322
|
+
canEdit: options?.canEdit,
|
|
311
323
|
});
|
|
312
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
iframe,
|
|
326
|
+
clip,
|
|
327
|
+
commit,
|
|
328
|
+
writeProjectFile,
|
|
329
|
+
recordEdit,
|
|
330
|
+
showToast,
|
|
331
|
+
reloadPreview,
|
|
332
|
+
fetchMock,
|
|
333
|
+
...hook,
|
|
334
|
+
};
|
|
313
335
|
}
|
|
314
336
|
|
|
315
337
|
const SDK_KEYFRAMED_SOURCE = [
|
|
@@ -1431,3 +1453,300 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
|
|
|
1431
1453
|
succeeding.unmount();
|
|
1432
1454
|
});
|
|
1433
1455
|
});
|
|
1456
|
+
|
|
1457
|
+
// Blocked means no fetch write, no recordEdit, and the host's reason
|
|
1458
|
+
// toasted. Absent canEdit behaves exactly as before (asserted above).
|
|
1459
|
+
describe("useTimelineEditing: canEdit gate", () => {
|
|
1460
|
+
it("refuses a move with the host's reason, writing nothing", async () => {
|
|
1461
|
+
const { clip, move, writeProjectFile, recordEdit, showToast, unmount } = setupSingleClipHarness(
|
|
1462
|
+
{
|
|
1463
|
+
canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
|
|
1464
|
+
},
|
|
1465
|
+
);
|
|
1466
|
+
|
|
1467
|
+
await act(async () => {
|
|
1468
|
+
await move(clip, { start: 3, track: clip.track });
|
|
1469
|
+
await flushAsyncWork();
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1473
|
+
expect(recordEdit).not.toHaveBeenCalled();
|
|
1474
|
+
expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
|
|
1475
|
+
unmount();
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
it("refuses a delete (single and multi) with nothing written", async () => {
|
|
1479
|
+
const { clip, del, elementsDelete, writeProjectFile, recordEdit, unmount } =
|
|
1480
|
+
setupSingleClipHarness({
|
|
1481
|
+
canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
await act(async () => {
|
|
1485
|
+
await del(clip);
|
|
1486
|
+
await elementsDelete([clip]);
|
|
1487
|
+
await flushAsyncWork();
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1491
|
+
expect(recordEdit).not.toHaveBeenCalled();
|
|
1492
|
+
unmount();
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it("resolves toggle-track-hidden against the expanded rows, not the raw timelineElements prop", async () => {
|
|
1496
|
+
// Regression: canEdit must resolve against the expanded rows
|
|
1497
|
+
// (timelineTrackVisibility.ts's own invariant), not the raw
|
|
1498
|
+
// timelineElements prop — proven by making the two disagree below.
|
|
1499
|
+
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
1500
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
1501
|
+
usePlayerStore.getState().setElements([clip]);
|
|
1502
|
+
const showToast = vi.fn();
|
|
1503
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
1504
|
+
let hook: ReturnType<typeof useTimelineEditing> | null = null;
|
|
1505
|
+
function Harness() {
|
|
1506
|
+
hook = useTimelineEditing({
|
|
1507
|
+
projectId: "p1",
|
|
1508
|
+
activeCompPath: "index.html",
|
|
1509
|
+
timelineElements: [], // deliberately does not contain `clip`
|
|
1510
|
+
showToast,
|
|
1511
|
+
writeProjectFile,
|
|
1512
|
+
recordEdit: vi.fn(),
|
|
1513
|
+
reloadPreview: vi.fn(),
|
|
1514
|
+
previewIframeRef: { current: iframe },
|
|
1515
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
1516
|
+
uploadProjectFiles: vi.fn(),
|
|
1517
|
+
canEdit: (element) =>
|
|
1518
|
+
element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
|
|
1519
|
+
});
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
const { unmount } = mountHarness(<Harness />);
|
|
1523
|
+
if (!hook) throw new Error("Expected hook to mount");
|
|
1524
|
+
|
|
1525
|
+
await act(async () => {
|
|
1526
|
+
await hook!.handleToggleTrackHidden(0, true);
|
|
1527
|
+
await flushAsyncWork();
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
|
|
1531
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1532
|
+
unmount();
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
it("lets an unblocked element through while a blocked one is refused", async () => {
|
|
1536
|
+
const iframe = createPreviewIframe([
|
|
1537
|
+
{ id: "free", track: 0 },
|
|
1538
|
+
{ id: "locked", track: 1 },
|
|
1539
|
+
]);
|
|
1540
|
+
const free = timelineElement({ id: "free", track: 0, zIndex: 0 });
|
|
1541
|
+
const locked = timelineElement({ id: "locked", track: 1, zIndex: 0 });
|
|
1542
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
1543
|
+
const recordEdit = vi.fn(async () => {});
|
|
1544
|
+
stubProjectFetch(
|
|
1545
|
+
'<div id="free" data-start="0" data-track-index="0"></div>' +
|
|
1546
|
+
'<div id="locked" data-start="0" data-track-index="1"></div>',
|
|
1547
|
+
);
|
|
1548
|
+
const hook = renderTimelineEditingHook({
|
|
1549
|
+
timelineElements: [free, locked],
|
|
1550
|
+
iframe,
|
|
1551
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1552
|
+
projectId: "p1",
|
|
1553
|
+
writeProjectFile,
|
|
1554
|
+
recordEdit,
|
|
1555
|
+
canEdit: (element) =>
|
|
1556
|
+
element.id === "locked" ? { blocked: true, reason: "Reserved by an agent" } : true,
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
await act(async () => {
|
|
1560
|
+
await hook.move(locked, { start: 3, track: locked.track });
|
|
1561
|
+
await flushAsyncWork();
|
|
1562
|
+
});
|
|
1563
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1564
|
+
|
|
1565
|
+
await act(async () => {
|
|
1566
|
+
await hook.move(free, { start: 3, track: free.track });
|
|
1567
|
+
await flushAsyncWork();
|
|
1568
|
+
});
|
|
1569
|
+
expect(writeProjectFile).toHaveBeenCalled();
|
|
1570
|
+
hook.unmount();
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
it("refuses razor-split-all when it would split a blocked clip, writing nothing", async () => {
|
|
1574
|
+
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
1575
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 2 });
|
|
1576
|
+
usePlayerStore.getState().setElements([clip]);
|
|
1577
|
+
const showToast = vi.fn();
|
|
1578
|
+
const fetchMock = vi.fn(async () => {
|
|
1579
|
+
throw new Error("must not be called: canEdit should have refused the split");
|
|
1580
|
+
});
|
|
1581
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1582
|
+
let hook: ReturnType<typeof useTimelineEditing> | null = null;
|
|
1583
|
+
function Harness() {
|
|
1584
|
+
hook = useTimelineEditing({
|
|
1585
|
+
projectId: "p1",
|
|
1586
|
+
activeCompPath: "index.html",
|
|
1587
|
+
timelineElements: [clip],
|
|
1588
|
+
showToast,
|
|
1589
|
+
writeProjectFile: vi.fn(),
|
|
1590
|
+
recordEdit: vi.fn(),
|
|
1591
|
+
reloadPreview: vi.fn(),
|
|
1592
|
+
previewIframeRef: { current: iframe },
|
|
1593
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
1594
|
+
uploadProjectFiles: vi.fn(),
|
|
1595
|
+
canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
|
|
1596
|
+
});
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
const { unmount } = mountHarness(<Harness />);
|
|
1600
|
+
if (!hook) throw new Error("Expected hook to mount");
|
|
1601
|
+
|
|
1602
|
+
await act(async () => {
|
|
1603
|
+
await hook!.handleRazorSplitAll(1);
|
|
1604
|
+
await flushAsyncWork();
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
|
|
1608
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1609
|
+
unmount();
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
it("refuses a blocked group's audio attribute write, writing nothing", async () => {
|
|
1613
|
+
const iframe = createPreviewIframe([{ id: "member", track: 0 }]);
|
|
1614
|
+
const member = timelineElement({ id: "member", track: 0, zIndex: 0 });
|
|
1615
|
+
usePlayerStore.getState().setElements([{ ...member, audioGroup: "hf-group" }]);
|
|
1616
|
+
const showToast = vi.fn();
|
|
1617
|
+
const fetchMock = vi.fn(async () => {
|
|
1618
|
+
throw new Error("must not be called: canEdit should have refused the write");
|
|
1619
|
+
});
|
|
1620
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1621
|
+
let hook: ReturnType<typeof useTimelineEditing> | null = null;
|
|
1622
|
+
function Harness() {
|
|
1623
|
+
hook = useTimelineEditing({
|
|
1624
|
+
projectId: "p1",
|
|
1625
|
+
activeCompPath: "index.html",
|
|
1626
|
+
timelineElements: [member],
|
|
1627
|
+
showToast,
|
|
1628
|
+
writeProjectFile: vi.fn(),
|
|
1629
|
+
recordEdit: vi.fn(),
|
|
1630
|
+
reloadPreview: vi.fn(),
|
|
1631
|
+
previewIframeRef: { current: iframe },
|
|
1632
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
1633
|
+
uploadProjectFiles: vi.fn(),
|
|
1634
|
+
canEdit: (element) =>
|
|
1635
|
+
element.id === "member" ? { blocked: true, reason: "Reserved by an agent" } : true,
|
|
1636
|
+
});
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
const { unmount } = mountHarness(<Harness />);
|
|
1640
|
+
if (!hook) throw new Error("Expected hook to mount");
|
|
1641
|
+
|
|
1642
|
+
await act(async () => {
|
|
1643
|
+
await hook!.setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Set volume");
|
|
1644
|
+
await flushAsyncWork();
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
|
|
1648
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1649
|
+
unmount();
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
it("refuses a group write when its members live only inside a sub-composition", async () => {
|
|
1653
|
+
// syncStoredGroupAttribute mirrors into domClipChildren for a group with
|
|
1654
|
+
// no flat twin (timelineAudioGroupVolume.ts) — the resolver must check
|
|
1655
|
+
// that array too, or a sub-comp-only group's write goes ungated.
|
|
1656
|
+
const iframe = createPreviewIframe([]);
|
|
1657
|
+
usePlayerStore.getState().setElements([]);
|
|
1658
|
+
usePlayerStore.getState().setDomClipChildren([
|
|
1659
|
+
{
|
|
1660
|
+
id: "sub-member",
|
|
1661
|
+
parentId: "host",
|
|
1662
|
+
hostId: "host",
|
|
1663
|
+
label: "Sub member",
|
|
1664
|
+
stackingContextId: "root",
|
|
1665
|
+
audioGroup: "hf-group",
|
|
1666
|
+
},
|
|
1667
|
+
]);
|
|
1668
|
+
const showToast = vi.fn();
|
|
1669
|
+
const fetchMock = vi.fn(async () => {
|
|
1670
|
+
throw new Error("must not be called: canEdit should have refused the write");
|
|
1671
|
+
});
|
|
1672
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1673
|
+
let hook: ReturnType<typeof useTimelineEditing> | null = null;
|
|
1674
|
+
function Harness() {
|
|
1675
|
+
hook = useTimelineEditing({
|
|
1676
|
+
projectId: "p1",
|
|
1677
|
+
activeCompPath: "index.html",
|
|
1678
|
+
timelineElements: [],
|
|
1679
|
+
showToast,
|
|
1680
|
+
writeProjectFile: vi.fn(),
|
|
1681
|
+
recordEdit: vi.fn(),
|
|
1682
|
+
reloadPreview: vi.fn(),
|
|
1683
|
+
previewIframeRef: { current: iframe },
|
|
1684
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
1685
|
+
uploadProjectFiles: vi.fn(),
|
|
1686
|
+
canEdit: (element) =>
|
|
1687
|
+
element.id === "sub-member" ? { blocked: true, reason: "Reserved by an agent" } : true,
|
|
1688
|
+
});
|
|
1689
|
+
return null;
|
|
1690
|
+
}
|
|
1691
|
+
const { unmount } = mountHarness(<Harness />);
|
|
1692
|
+
if (!hook) throw new Error("Expected hook to mount");
|
|
1693
|
+
|
|
1694
|
+
await act(async () => {
|
|
1695
|
+
await hook!.setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Set volume");
|
|
1696
|
+
await flushAsyncWork();
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
|
|
1700
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1701
|
+
unmount();
|
|
1702
|
+
});
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
// Regression: track()/guard() must return the same wrapped handler across
|
|
1706
|
+
// renders for the same fn, or a consumer using it as a memo/effect
|
|
1707
|
+
// dependency re-runs on every render for nothing.
|
|
1708
|
+
describe("useTimelineEditing: handler identity is stable across renders", () => {
|
|
1709
|
+
it("returns the same handleTimelineElementMove reference on a re-render", () => {
|
|
1710
|
+
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
1711
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
1712
|
+
stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
|
|
1713
|
+
// Every option below is hoisted (created once), matching a real caller's
|
|
1714
|
+
// stable useCallback/selector inputs — a fresh vi.fn() per render would
|
|
1715
|
+
// change handleTimelineElementMove's own identity regardless of the fix.
|
|
1716
|
+
const timelineElements = [clip];
|
|
1717
|
+
const showToast = vi.fn();
|
|
1718
|
+
const writeProjectFile = vi.fn();
|
|
1719
|
+
const recordEdit = vi.fn();
|
|
1720
|
+
const reloadPreview = vi.fn();
|
|
1721
|
+
const uploadProjectFiles = vi.fn();
|
|
1722
|
+
const previewIframeRef = { current: iframe };
|
|
1723
|
+
const pendingTimelineEditPathRef = { current: new Set<string>() };
|
|
1724
|
+
const seen: unknown[] = [];
|
|
1725
|
+
let bumpTick = 0;
|
|
1726
|
+
let bump = () => {};
|
|
1727
|
+
function Harness() {
|
|
1728
|
+
const [, setTick] = React.useState(0);
|
|
1729
|
+
bump = () => setTick((t) => t + 1);
|
|
1730
|
+
bumpTick += 1;
|
|
1731
|
+
const hook = useTimelineEditing({
|
|
1732
|
+
projectId: "p1",
|
|
1733
|
+
activeCompPath: "index.html",
|
|
1734
|
+
timelineElements,
|
|
1735
|
+
showToast,
|
|
1736
|
+
writeProjectFile,
|
|
1737
|
+
recordEdit,
|
|
1738
|
+
reloadPreview,
|
|
1739
|
+
previewIframeRef,
|
|
1740
|
+
pendingTimelineEditPathRef,
|
|
1741
|
+
uploadProjectFiles,
|
|
1742
|
+
});
|
|
1743
|
+
seen.push(hook.handleTimelineElementMove);
|
|
1744
|
+
return null;
|
|
1745
|
+
}
|
|
1746
|
+
const { unmount } = mountHarness(<Harness />);
|
|
1747
|
+
act(() => bump());
|
|
1748
|
+
expect(bumpTick).toBeGreaterThan(1);
|
|
1749
|
+
expect(seen[0]).toBe(seen[1]);
|
|
1750
|
+
unmount();
|
|
1751
|
+
});
|
|
1752
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// fallow-ignore-file complexity
|
|
2
2
|
import { useCallback, useRef } from "react";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
|
+
import { usePlayerStore } from "../player";
|
|
4
5
|
import { useRazorSplit } from "./useRazorSplit";
|
|
6
|
+
import { selectSplittableElements } from "../utils/timelineElementSplit";
|
|
5
7
|
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
6
8
|
import {
|
|
7
9
|
applyTimelineStackingReorder,
|
|
@@ -23,6 +25,8 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
|
23
25
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
24
26
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
25
27
|
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
28
|
+
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
29
|
+
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
26
30
|
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
27
31
|
import {
|
|
28
32
|
useTimelineElementVisibilityEditing,
|
|
@@ -30,6 +34,7 @@ import {
|
|
|
30
34
|
} from "./timelineTrackVisibility";
|
|
31
35
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
32
36
|
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
|
|
37
|
+
import { useTimelineEditGate } from "./timelineEditPermission";
|
|
33
38
|
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
34
39
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
35
40
|
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
@@ -53,10 +58,38 @@ export function useTimelineEditing({
|
|
|
53
58
|
forceReloadSdkSession,
|
|
54
59
|
invalidateGsapCache,
|
|
55
60
|
handleDomZIndexReorderCommitRef,
|
|
61
|
+
canEdit,
|
|
56
62
|
}: UseTimelineEditingOptions) {
|
|
57
63
|
const projectIdRef = useRef(projectId);
|
|
58
64
|
projectIdRef.current = projectId;
|
|
59
65
|
const editQueueRef = useRef(Promise.resolve());
|
|
66
|
+
const track = useTrackPendingTimelineEdit();
|
|
67
|
+
const checkEditable = useTimelineEditGate(canEdit, showToast);
|
|
68
|
+
// Same expanded-row source the hide handlers themselves resolve against
|
|
69
|
+
// (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
|
|
70
|
+
// only exists here, not in the raw store list canEdit would otherwise miss.
|
|
71
|
+
const timelineRowElements = useTimelineRowElements();
|
|
72
|
+
const guardedRef = useRef(
|
|
73
|
+
new WeakMap<(...args: never[]) => Promise<void>, (...args: never[]) => Promise<void>>(),
|
|
74
|
+
);
|
|
75
|
+
// Refuses (no call, no write, no history entry) when any target is
|
|
76
|
+
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
77
|
+
// track() — so a fresh closure here doesn't defeat track's own cache.
|
|
78
|
+
const guard = useCallback(
|
|
79
|
+
<H extends (...args: never[]) => Promise<void>>(
|
|
80
|
+
resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
|
|
81
|
+
fn: H,
|
|
82
|
+
): H => {
|
|
83
|
+
const key = fn as unknown as (...args: never[]) => Promise<void>;
|
|
84
|
+
const cached = guardedRef.current.get(key);
|
|
85
|
+
if (cached) return cached as H;
|
|
86
|
+
const wrapped = ((...args: Parameters<H>) =>
|
|
87
|
+
checkEditable(resolveTargets(...args)) ? fn(...args) : Promise.resolve()) as H;
|
|
88
|
+
guardedRef.current.set(key, wrapped as unknown as (...args: never[]) => Promise<void>);
|
|
89
|
+
return wrapped;
|
|
90
|
+
},
|
|
91
|
+
[checkEditable],
|
|
92
|
+
);
|
|
60
93
|
|
|
61
94
|
const enqueueEdit = useCallback(
|
|
62
95
|
(
|
|
@@ -445,23 +478,77 @@ export function useTimelineEditing({
|
|
|
445
478
|
forceReloadSdkSession,
|
|
446
479
|
});
|
|
447
480
|
|
|
481
|
+
// Every write-handler is tracked here, the one place all hand edits
|
|
482
|
+
// converge, so undo never races a write; canEdit gates the same point.
|
|
483
|
+
// Coverage boundary: see the PR body, not every kind resolves an element.
|
|
484
|
+
const trackedRazorSplit = track(guard((element) => [element], handleRazorSplit));
|
|
448
485
|
return {
|
|
449
|
-
handleTimelineElementMove,
|
|
450
|
-
handleTimelineElementResize,
|
|
451
|
-
handleToggleTrackHidden
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
486
|
+
handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
|
|
487
|
+
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
|
|
488
|
+
handleToggleTrackHidden: track(
|
|
489
|
+
guard(
|
|
490
|
+
(trackIndex) => timelineRowElements.filter((el) => el.track === trackIndex),
|
|
491
|
+
handleToggleTrackHidden,
|
|
492
|
+
),
|
|
493
|
+
),
|
|
494
|
+
handleToggleElementHidden: track(
|
|
495
|
+
guard((elementKey) => {
|
|
496
|
+
const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
|
|
497
|
+
return timelineRowElements.filter((el) => keys.has(el.key ?? el.id));
|
|
498
|
+
}, handleToggleElementHidden),
|
|
499
|
+
),
|
|
500
|
+
handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
|
|
501
|
+
setAudioGroupAttribute: {
|
|
502
|
+
...setAudioGroupAttribute,
|
|
503
|
+
// Same two-array member lookup syncStoredGroupAttribute mirrors into
|
|
504
|
+
// (timelineAudioGroupVolume.ts): a sub-composition's group members have
|
|
505
|
+
// no flat twin, only a domClipChildren entry, so both are checked.
|
|
506
|
+
setQuiet: track(
|
|
507
|
+
guard((groupId) => {
|
|
508
|
+
const state = usePlayerStore.getState();
|
|
509
|
+
const flatMembers = state.elements.filter((el) => el.audioGroup === groupId);
|
|
510
|
+
const domMembers = state.domClipChildren
|
|
511
|
+
.filter((child) => child.audioGroup === groupId)
|
|
512
|
+
.map(
|
|
513
|
+
(child): TimelineElement => ({
|
|
514
|
+
id: child.id,
|
|
515
|
+
domId: child.id,
|
|
516
|
+
tag: "div",
|
|
517
|
+
start: 0,
|
|
518
|
+
duration: 0,
|
|
519
|
+
track: -1,
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
return [...flatMembers, ...domMembers];
|
|
523
|
+
}, setAudioGroupAttribute.setQuiet),
|
|
524
|
+
),
|
|
525
|
+
},
|
|
526
|
+
setElementFxAttribute: {
|
|
527
|
+
...setElementFxAttribute,
|
|
528
|
+
setQuiet: track(guard((element) => [element], setElementFxAttribute.setQuiet)),
|
|
529
|
+
},
|
|
530
|
+
handleTimelineElementDelete: track(guard((element) => [element], handleTimelineElementDelete)),
|
|
531
|
+
handleTimelineElementsDelete: track(
|
|
532
|
+
guard((elements) => elements, handleTimelineElementsDelete),
|
|
533
|
+
),
|
|
534
|
+
handleTimelineElementSplit: trackedRazorSplit,
|
|
535
|
+
handleRazorSplit: trackedRazorSplit,
|
|
536
|
+
// Same selection the handler itself splits (useRazorSplit.ts).
|
|
537
|
+
handleRazorSplitAll: track(
|
|
538
|
+
guard(
|
|
539
|
+
(splitTime) => selectSplittableElements(usePlayerStore.getState().elements, splitTime),
|
|
540
|
+
handleRazorSplitAll,
|
|
541
|
+
),
|
|
542
|
+
),
|
|
543
|
+
handleTimelineAssetDrop: track(handleTimelineAssetDrop),
|
|
544
|
+
handleTimelineFileDrop: track(handleTimelineFileDrop),
|
|
545
|
+
handleTimelineCompositionDrop: track(handleTimelineCompositionDrop),
|
|
464
546
|
handleBlockedTimelineEdit,
|
|
465
|
-
|
|
547
|
+
handleTimelineGroupMove: track(
|
|
548
|
+
guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupMove),
|
|
549
|
+
),
|
|
550
|
+
handleTimelineGroupResize: track(
|
|
551
|
+
guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupResize),
|
|
552
|
+
),
|
|
466
553
|
};
|
|
467
554
|
}
|
|
@@ -4,6 +4,7 @@ import type { TimelineElement } from "../player";
|
|
|
4
4
|
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
|
5
5
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
6
6
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
7
|
+
import type { CanEditTimelineElement } from "./timelineEditPermission";
|
|
7
8
|
|
|
8
9
|
interface RecordEditInput {
|
|
9
10
|
label: string;
|
|
@@ -49,6 +50,8 @@ export interface UseTimelineEditingOptions {
|
|
|
49
50
|
/** Reparse authored animations after a timing rewrite changes their positions. */
|
|
50
51
|
invalidateGsapCache?: () => void;
|
|
51
52
|
handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
|
|
53
|
+
/** Refuses a hand edit at the persist boundary when set; absent = Studio unchanged. */
|
|
54
|
+
canEdit?: CanEditTimelineElement;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
export type TimelineFileDropHandler = (
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
|
|
7
|
+
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
let root: Root | null = null;
|
|
12
|
+
afterEach(() => act(() => root?.unmount()));
|
|
13
|
+
|
|
14
|
+
function mount() {
|
|
15
|
+
let track!: ReturnType<typeof useTrackPendingTimelineEdit>;
|
|
16
|
+
function Probe() {
|
|
17
|
+
track = useTrackPendingTimelineEdit();
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
root = createRoot(document.createElement("div"));
|
|
21
|
+
act(() => root!.render(createElement(Probe)));
|
|
22
|
+
return track;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const deferred = <T,>() => {
|
|
26
|
+
let resolve!: (value: T) => void;
|
|
27
|
+
const promise = new Promise<T>((r) => (resolve = r));
|
|
28
|
+
return { promise, resolve };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("useTrackPendingTimelineEdit", () => {
|
|
32
|
+
it("feeds Studio's shared pending-edit registry, so the app-wide flush waits for it", async () => {
|
|
33
|
+
const track = mount();
|
|
34
|
+
const gate = deferred<void>();
|
|
35
|
+
const order: string[] = [];
|
|
36
|
+
const tracked = track(() => gate.promise.then(() => order.push("edit-settled")));
|
|
37
|
+
const call = tracked();
|
|
38
|
+
const flushPromise = flushStudioPendingEdits().then(() => order.push("flushed"));
|
|
39
|
+
// Give the flush's own microtasks a turn: if tracking did nothing, it
|
|
40
|
+
// would already show "flushed" here, before the edit ever settles.
|
|
41
|
+
await Promise.resolve();
|
|
42
|
+
await Promise.resolve();
|
|
43
|
+
expect(order).toEqual([]);
|
|
44
|
+
gate.resolve();
|
|
45
|
+
await call;
|
|
46
|
+
await flushPromise;
|
|
47
|
+
expect(order).toEqual(["edit-settled", "flushed"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns the same wrapper for the same handler across calls", () => {
|
|
51
|
+
const track = mount();
|
|
52
|
+
const handler = () => Promise.resolve();
|
|
53
|
+
expect(track(handler)).toBe(track(handler));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns a different wrapper for a different handler", () => {
|
|
57
|
+
const track = mount();
|
|
58
|
+
const a = () => Promise.resolve();
|
|
59
|
+
const b = () => Promise.resolve();
|
|
60
|
+
expect(track(a)).not.toBe(track(b));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
|
|
3
|
+
|
|
4
|
+
type AsyncFn = (...args: never[]) => Promise<unknown>;
|
|
5
|
+
|
|
6
|
+
// Tracks a hand-edit handler's write so undo/redo (already draining this
|
|
7
|
+
// registry) waits for it. Same `fn` in, same wrapper out, so memoized deps stay stable.
|
|
8
|
+
export function useTrackPendingTimelineEdit() {
|
|
9
|
+
const wrappedRef = useRef(new WeakMap<AsyncFn, AsyncFn>());
|
|
10
|
+
return useCallback(<Args extends unknown[], R>(fn: (...args: Args) => Promise<R>) => {
|
|
11
|
+
const key = fn as unknown as AsyncFn;
|
|
12
|
+
const cached = wrappedRef.current.get(key);
|
|
13
|
+
if (cached) return cached as unknown as (...args: Args) => Promise<R>;
|
|
14
|
+
const wrapped = (...args: Args): Promise<R> => {
|
|
15
|
+
const result = fn(...args);
|
|
16
|
+
trackStudioPendingEdit(result);
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
|
|
20
|
+
return wrapped;
|
|
21
|
+
}, []);
|
|
22
|
+
}
|