@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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore";
|
|
3
|
+
|
|
4
|
+
/** Opens the Slideshow panel when the file becomes a slideshow and closes it when it stops being one; a user's own close, and a restored layout's placement, stick. */
|
|
5
|
+
export function useSlideshowDockPanel(isSlideshowComposition: boolean) {
|
|
6
|
+
const controller = useDockLayoutStore((state) => state.controller);
|
|
7
|
+
const wasSlideshow = useRef(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!controller) return;
|
|
10
|
+
const inDock = useDockLayoutStore.getState().openPanels.has("slideshow");
|
|
11
|
+
if (isSlideshowComposition && !inDock) controller.open("slideshow");
|
|
12
|
+
if (!isSlideshowComposition && wasSlideshow.current && inDock) controller.close("slideshow");
|
|
13
|
+
wasSlideshow.current = isSlideshowComposition;
|
|
14
|
+
}, [isSlideshowComposition, controller]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Block params replace the Design body until Design stops showing; picking another tab dismisses them elsewhere. */
|
|
18
|
+
export function useBlockParamsDismissal({
|
|
19
|
+
hasBlockParams,
|
|
20
|
+
onDismiss,
|
|
21
|
+
}: {
|
|
22
|
+
hasBlockParams: boolean;
|
|
23
|
+
onDismiss: () => void;
|
|
24
|
+
}) {
|
|
25
|
+
const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design"));
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (hasBlockParams && !designVisible) onDismiss();
|
|
28
|
+
}, [hasBlockParams, designVisible, onDismiss]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Wraps a tab setter so any tab other than block-params first dismisses the block-params view. */
|
|
32
|
+
export function useDismissingTabSetter<Tab extends string>(
|
|
33
|
+
setTab: (tab: Tab) => void,
|
|
34
|
+
dismissBlockParams: () => void,
|
|
35
|
+
) {
|
|
36
|
+
return useCallback(
|
|
37
|
+
(tab: Tab) => {
|
|
38
|
+
if (tab !== "block-params") dismissBlockParams();
|
|
39
|
+
setTab(tab);
|
|
40
|
+
},
|
|
41
|
+
[setTab, dismissBlockParams],
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Caption edit mode owns the Design panel: whenever the right column shows something else, bring Design back. */
|
|
46
|
+
export function useCaptionDesignFocus(captionEditMode: boolean) {
|
|
47
|
+
const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design"));
|
|
48
|
+
const rightShown = useDockLayoutStore(
|
|
49
|
+
(state) => visiblePanelInZone("right", state.lastActive, state.visiblePanels) !== null,
|
|
50
|
+
);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (captionEditMode && rightShown && !designVisible) {
|
|
53
|
+
useDockLayoutStore.getState().activatePanel("design");
|
|
54
|
+
}
|
|
55
|
+
}, [captionEditMode, rightShown, designVisible]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The right-column panel the user last focused, whether or not the column is currently showing. */
|
|
59
|
+
export function useRightPanelIntent() {
|
|
60
|
+
return useDockLayoutStore((state) =>
|
|
61
|
+
visiblePanelInZone("right", state.lastActive, state.openPanels),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -164,17 +164,62 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
164
164
|
|
|
165
165
|
// Every cutover chokepoint silently takes the server path when there is no
|
|
166
166
|
// session, and the shadow never runs either — so a missing session is a total,
|
|
167
|
-
// otherwise-invisible SDK bypass. These
|
|
168
|
-
|
|
167
|
+
// otherwise-invisible SDK bypass. These exits are its only origin.
|
|
168
|
+
//
|
|
169
|
+
// `stage: read` carries a reason because it was the largest remaining class
|
|
170
|
+
// and a single opaque string: 56 users hit it in a 7-day window and never
|
|
171
|
+
// landed one successful SDK edit between them, with no way to tell a genuinely
|
|
172
|
+
// absent file from a request that never reached one.
|
|
173
|
+
it("reports the HTTP status when the read fails", async () => {
|
|
169
174
|
vi.stubGlobal(
|
|
170
175
|
"fetch",
|
|
171
|
-
vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
|
|
176
|
+
vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }) as Response),
|
|
172
177
|
);
|
|
173
178
|
const root = createRoot(document.createElement("div"));
|
|
174
179
|
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
175
180
|
await flushAsyncEffects();
|
|
176
181
|
|
|
177
|
-
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
182
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
183
|
+
stage: "read",
|
|
184
|
+
reason: "http_error",
|
|
185
|
+
status: 404,
|
|
186
|
+
});
|
|
187
|
+
await act(async () => root.unmount());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("separates an unexpected response shape from a failed request", async () => {
|
|
191
|
+
vi.stubGlobal(
|
|
192
|
+
"fetch",
|
|
193
|
+
vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response),
|
|
194
|
+
);
|
|
195
|
+
const root = createRoot(document.createElement("div"));
|
|
196
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
197
|
+
await flushAsyncEffects();
|
|
198
|
+
|
|
199
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
200
|
+
stage: "read",
|
|
201
|
+
reason: "missing_content",
|
|
202
|
+
});
|
|
203
|
+
await act(async () => root.unmount());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// `optional=1` answers a file that is not on disk with 200 + an empty string,
|
|
207
|
+
// so this is what "the composition genuinely is not there" looks like on the
|
|
208
|
+
// wire — previously indistinguishable from a broken request. The shim and a
|
|
209
|
+
// real 0-byte file are the same response, hence the name.
|
|
210
|
+
it("separates a file that is not on disk", async () => {
|
|
211
|
+
vi.stubGlobal(
|
|
212
|
+
"fetch",
|
|
213
|
+
vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
|
|
214
|
+
);
|
|
215
|
+
const root = createRoot(document.createElement("div"));
|
|
216
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
217
|
+
await flushAsyncEffects();
|
|
218
|
+
|
|
219
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
220
|
+
stage: "read",
|
|
221
|
+
reason: "absent_or_empty",
|
|
222
|
+
});
|
|
178
223
|
await act(async () => root.unmount());
|
|
179
224
|
});
|
|
180
225
|
|
|
@@ -7,25 +7,53 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
|
7
7
|
import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Why an optional project-file read produced no usable content. `stage: "read"`
|
|
11
|
+
* was a single opaque reason covering all of these, which made the largest
|
|
12
|
+
* remaining class of SDK-session failures undiagnosable: 56 users in a 7-day
|
|
13
|
+
* window hit it and, between them, never landed a single successful SDK edit.
|
|
14
|
+
* Knowing which branch fired is the difference between "the file legitimately
|
|
15
|
+
* is not there" and "the request never reached the file".
|
|
16
|
+
*
|
|
17
|
+
* Every reason lives in this union so the full surface is readable from one
|
|
18
|
+
* place — `absent_or_empty` included, even though it is a 2xx.
|
|
19
|
+
*/
|
|
20
|
+
type ProjectFileReadFailure =
|
|
21
|
+
| { ok: false; reason: "unsafe_path" }
|
|
22
|
+
| { ok: false; reason: "http_error"; status: number }
|
|
23
|
+
| { ok: false; reason: "missing_content" }
|
|
24
|
+
| { ok: false; reason: "absent_or_empty" };
|
|
25
|
+
|
|
26
|
+
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read a project file's content (optional read — a missing file is not an
|
|
30
|
+
* error). Replaces the removed SDK http adapter's `read()` — the only thing
|
|
31
|
+
* Studio used it for (Studio is the sole writer, so the adapter's write path
|
|
32
|
+
* was dead).
|
|
13
33
|
*/
|
|
14
34
|
async function readProjectFileOptional(
|
|
15
35
|
projectId: string,
|
|
16
36
|
path: string,
|
|
17
|
-
): Promise<
|
|
37
|
+
): Promise<ProjectFileReadResult> {
|
|
18
38
|
// Reject traversal / NUL before building the request URL — `path` is a
|
|
19
39
|
// user-influenced composition path (mirrors the guard in timelineEditingHelpers,
|
|
20
40
|
// and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
|
|
21
41
|
// already confines both values to single segments of this same-origin URL.
|
|
22
|
-
if (path.includes("\0") || path.includes("..")) return
|
|
42
|
+
if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
|
|
23
43
|
const res = await fetch(
|
|
24
44
|
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
25
45
|
);
|
|
26
|
-
if (!res.ok) return
|
|
46
|
+
if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
|
|
27
47
|
const data = (await res.json()) as { content?: string };
|
|
28
|
-
|
|
48
|
+
// `optional=1` answers a missing file with 200 + `content: ""`, so a
|
|
49
|
+
// non-string here means a response shape we did not expect, not absence.
|
|
50
|
+
if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
|
|
51
|
+
// An empty body parses into a session with no elements, which declines every
|
|
52
|
+
// edit wholesale — not a session worth opening. The absent-file shim and a
|
|
53
|
+
// genuinely 0-byte file are the same 200 on the wire and cannot be told
|
|
54
|
+
// apart here, hence the name; for a composition it is always the former.
|
|
55
|
+
if (data.content === "") return { ok: false, reason: "absent_or_empty" };
|
|
56
|
+
return { ok: true, content: data.content };
|
|
29
57
|
}
|
|
30
58
|
|
|
31
59
|
/**
|
|
@@ -180,16 +208,21 @@ export function useSdkSession(
|
|
|
180
208
|
};
|
|
181
209
|
|
|
182
210
|
readProjectFileOptional(projectId, activeCompPath)
|
|
183
|
-
.then(async (
|
|
211
|
+
.then(async (read) => {
|
|
184
212
|
if (cancelled) return;
|
|
185
|
-
if (
|
|
213
|
+
if (!read.ok) {
|
|
186
214
|
// No SDK session follows, so EVERY cutover chokepoint below takes the
|
|
187
215
|
// server path and emits nothing — the shadow never runs either. This
|
|
188
216
|
// is the only place a missing session can originate, so a broken read
|
|
189
217
|
// would otherwise be a silent, total SDK bypass.
|
|
190
|
-
trackStudioEvent("sdk_session_unavailable", {
|
|
218
|
+
trackStudioEvent("sdk_session_unavailable", {
|
|
219
|
+
stage: "read",
|
|
220
|
+
reason: read.reason,
|
|
221
|
+
...(read.reason === "http_error" ? { status: read.status } : {}),
|
|
222
|
+
});
|
|
191
223
|
return;
|
|
192
224
|
}
|
|
225
|
+
const content = read.content;
|
|
193
226
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
194
227
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
195
228
|
// queue too would double-write the file (queue auto-writes on every
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useServerConnection } from "./useServerConnection";
|
|
6
|
+
|
|
7
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
|
+
|
|
9
|
+
const captured: { projectId: string | null } = { projectId: null };
|
|
10
|
+
|
|
11
|
+
function Probe() {
|
|
12
|
+
captured.projectId = useServerConnection().projectId;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function flush(): Promise<void> {
|
|
17
|
+
await act(async () => {
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The hash project id outlives the project it names — a renamed folder, or a
|
|
24
|
+
* bookmark from a project that is gone. It used to be trusted unconditionally,
|
|
25
|
+
* so every later /api/projects/<id>/... request 404'd for the life of the tab,
|
|
26
|
+
* including the composition read that opens the SDK session. Telemetry after
|
|
27
|
+
* the read-reason split: 120 http_error/404 reads across 5 users in 24h.
|
|
28
|
+
*/
|
|
29
|
+
function stubFetch(projectRoute: { status: number } | "reject") {
|
|
30
|
+
vi.stubGlobal(
|
|
31
|
+
"fetch",
|
|
32
|
+
vi.fn(async (url: string) => {
|
|
33
|
+
if (url === "/api/projects") {
|
|
34
|
+
return { ok: true, json: async () => ({ projects: [{ id: "real-project" }] }) } as Response;
|
|
35
|
+
}
|
|
36
|
+
if (projectRoute === "reject") throw new TypeError("Failed to fetch");
|
|
37
|
+
return { ok: projectRoute.status === 200, status: projectRoute.status } as Response;
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function renderWithHash(hash: string) {
|
|
43
|
+
window.location.hash = hash;
|
|
44
|
+
const root = createRoot(document.createElement("div"));
|
|
45
|
+
await act(async () => root.render(<Probe />));
|
|
46
|
+
await flush();
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("useServerConnection hash project id", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
captured.projectId = null;
|
|
53
|
+
window.location.hash = "";
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps a hash id the server can resolve", async () => {
|
|
61
|
+
stubFetch({ status: 200 });
|
|
62
|
+
const root = await renderWithHash("#project/stale-or-not");
|
|
63
|
+
|
|
64
|
+
expect(captured.projectId).toBe("stale-or-not");
|
|
65
|
+
await act(async () => root.unmount());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to the first project when the hash id is gone", async () => {
|
|
69
|
+
stubFetch({ status: 404 });
|
|
70
|
+
const root = await renderWithHash("#project/deleted-project");
|
|
71
|
+
|
|
72
|
+
expect(captured.projectId).toBe("real-project");
|
|
73
|
+
expect(window.location.hash).toContain("real-project");
|
|
74
|
+
await act(async () => root.unmount());
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The important one: a blip must not rewrite the user's hash out from under a
|
|
78
|
+
// project that is actually fine. Only a definite 404 is "missing".
|
|
79
|
+
it("keeps the hash id when the check itself fails", async () => {
|
|
80
|
+
stubFetch("reject");
|
|
81
|
+
const root = await renderWithHash("#project/unreachable-check");
|
|
82
|
+
|
|
83
|
+
expect(captured.projectId).toBe("unreachable-check");
|
|
84
|
+
await act(async () => root.unmount());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps the hash id on a non-404 error status", async () => {
|
|
88
|
+
stubFetch({ status: 500 });
|
|
89
|
+
const root = await renderWithHash("#project/server-erroring");
|
|
90
|
+
|
|
91
|
+
expect(captured.projectId).toBe("server-erroring");
|
|
92
|
+
await act(async () => root.unmount());
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -18,6 +18,26 @@ interface ServerConnectionState {
|
|
|
18
18
|
* Polls every 2 s until the server responds, then transitions automatically.
|
|
19
19
|
* Cleans up pending timers on unmount so it is safe under React StrictMode.
|
|
20
20
|
*/
|
|
21
|
+
/**
|
|
22
|
+
* Whether a hash-supplied project id still names something the server can
|
|
23
|
+
* resolve. Three answers, not two: a failed request must NOT be read as
|
|
24
|
+
* "missing", or one network blip would discard a perfectly good deep link.
|
|
25
|
+
*
|
|
26
|
+
* Resolved through `/api/projects/:id`, which calls the same
|
|
27
|
+
* `adapter.resolveProject` the file routes use. A match against the
|
|
28
|
+
* `/api/projects` list would be wrong twice over: that list omits session ids
|
|
29
|
+
* (which resolve fine) and skips project dirs without an `index.html`.
|
|
30
|
+
*/
|
|
31
|
+
async function resolveHashProject(id: string): Promise<"ok" | "missing" | "unknown"> {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
|
|
34
|
+
if (res.ok) return "ok";
|
|
35
|
+
return res.status === 404 ? "missing" : "unknown";
|
|
36
|
+
} catch {
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
export function useServerConnection(): ServerConnectionState {
|
|
22
42
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
23
43
|
const [resolving, setResolving] = useState(true);
|
|
@@ -39,21 +59,34 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
39
59
|
function tryConnect() {
|
|
40
60
|
fetch("/api/projects")
|
|
41
61
|
.then((r) => r.json())
|
|
42
|
-
.then((data) => {
|
|
62
|
+
.then(async (data) => {
|
|
43
63
|
if (cancelled) return;
|
|
64
|
+
// A hash project id outlives the project it names — a renamed folder,
|
|
65
|
+
// or a bookmark from a project that is gone. Trusting it blindly made
|
|
66
|
+
// every later /api/projects/<id>/... request 404 for the life of the
|
|
67
|
+
// tab, including the composition read that opens the SDK session, so
|
|
68
|
+
// every edit fell back to the server path with nothing to show why.
|
|
69
|
+
// Telemetry after the read-reason split: 120 http_error/404 reads
|
|
70
|
+
// across 5 users in 24h, ~24 each, never recovering.
|
|
44
71
|
if (hashProjectId) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
setProjectId(
|
|
72
|
+
const state = await resolveHashProject(hashProjectId);
|
|
73
|
+
if (cancelled) return;
|
|
74
|
+
// "unknown" keeps the old behaviour: a transient failure must not
|
|
75
|
+
// rewrite the user's hash out from under a valid project.
|
|
76
|
+
if (state !== "missing") {
|
|
77
|
+
setProjectId(hashProjectId);
|
|
51
78
|
setWaitingForServer(false);
|
|
52
|
-
|
|
53
|
-
} else {
|
|
54
|
-
scheduleRetry();
|
|
79
|
+
return;
|
|
55
80
|
}
|
|
56
81
|
}
|
|
82
|
+
const first = (data.projects ?? [])[0];
|
|
83
|
+
if (first) {
|
|
84
|
+
setProjectId(first.id);
|
|
85
|
+
setWaitingForServer(false);
|
|
86
|
+
window.location.hash = buildProjectHash(first.id);
|
|
87
|
+
} else {
|
|
88
|
+
scheduleRetry();
|
|
89
|
+
}
|
|
57
90
|
})
|
|
58
91
|
.catch(() => {
|
|
59
92
|
if (!cancelled) scheduleRetry();
|
|
@@ -4,7 +4,6 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { useSlideshowTabState } from "./useSlideshowTabState";
|
|
7
|
-
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
8
7
|
|
|
9
8
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
9
|
|
|
@@ -15,14 +14,10 @@ afterEach(() => {
|
|
|
15
14
|
document.body.innerHTML = "";
|
|
16
15
|
});
|
|
17
16
|
|
|
18
|
-
function renderHook(params: {
|
|
19
|
-
editingFileContent: string | null | undefined;
|
|
20
|
-
rightPanelTab: RightPanelTab;
|
|
21
|
-
}) {
|
|
17
|
+
function renderHook(params: { editingFileContent: string | null | undefined }) {
|
|
22
18
|
const host = document.createElement("div");
|
|
23
19
|
document.body.append(host);
|
|
24
20
|
const root = createRoot(host);
|
|
25
|
-
const setRightPanelTabCalls: RightPanelTab[] = [];
|
|
26
21
|
let current: ReturnType<typeof useSlideshowTabState> | null = null;
|
|
27
22
|
|
|
28
23
|
function Harness() {
|
|
@@ -30,8 +25,7 @@ function renderHook(params: {
|
|
|
30
25
|
editingFileContent: params.editingFileContent,
|
|
31
26
|
previewIframeRef: { current: null },
|
|
32
27
|
refreshKey: 0,
|
|
33
|
-
|
|
34
|
-
setRightPanelTab: (tab) => setRightPanelTabCalls.push(tab),
|
|
28
|
+
slideshowVisible: false,
|
|
35
29
|
});
|
|
36
30
|
return null;
|
|
37
31
|
}
|
|
@@ -45,52 +39,36 @@ function renderHook(params: {
|
|
|
45
39
|
if (!current) throw new Error("useSlideshowTabState did not render");
|
|
46
40
|
return current;
|
|
47
41
|
},
|
|
48
|
-
setRightPanelTabCalls,
|
|
49
42
|
unmount: () => act(() => root.unmount()),
|
|
50
43
|
};
|
|
51
44
|
}
|
|
52
45
|
|
|
46
|
+
// Whether "slideshow" stays the active dock tab across a file switch is
|
|
47
|
+
// StudioRightPanels' own effect now (it opens/closes the dock panel), not
|
|
48
|
+
// this hook's job — this only derives the two values that effect reads.
|
|
53
49
|
describe("useSlideshowTabState", () => {
|
|
54
50
|
it("detects a slideshow composition via the JSON island", () => {
|
|
55
|
-
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML
|
|
51
|
+
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML });
|
|
56
52
|
expect(harness.getState().isSlideshowComposition).toBe(true);
|
|
57
53
|
harness.unmount();
|
|
58
54
|
});
|
|
59
55
|
|
|
60
56
|
it("reports false for a plain (non-slideshow) composition", () => {
|
|
61
|
-
const harness = renderHook({ editingFileContent: PLAIN_HTML
|
|
57
|
+
const harness = renderHook({ editingFileContent: PLAIN_HTML });
|
|
62
58
|
expect(harness.getState().isSlideshowComposition).toBe(false);
|
|
63
59
|
harness.unmount();
|
|
64
60
|
});
|
|
65
61
|
|
|
66
62
|
it("reports false when there is no editing file yet", () => {
|
|
67
|
-
const harness = renderHook({ editingFileContent: undefined
|
|
63
|
+
const harness = renderHook({ editingFileContent: undefined });
|
|
68
64
|
expect(harness.getState().isSlideshowComposition).toBe(false);
|
|
69
65
|
harness.unmount();
|
|
70
66
|
});
|
|
71
67
|
|
|
72
68
|
it("still detects a malformed island — presence-only, not full manifest validation", () => {
|
|
73
69
|
const malformed = `<html><body><script type="application/hyperframes-slideshow+json">{not valid json</script></body></html>`;
|
|
74
|
-
const harness = renderHook({ editingFileContent: malformed
|
|
70
|
+
const harness = renderHook({ editingFileContent: malformed });
|
|
75
71
|
expect(harness.getState().isSlideshowComposition).toBe(true);
|
|
76
72
|
harness.unmount();
|
|
77
73
|
});
|
|
78
|
-
|
|
79
|
-
it("bounces rightPanelTab off 'slideshow' to 'renders' on a non-slideshow composition", () => {
|
|
80
|
-
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "slideshow" });
|
|
81
|
-
expect(harness.setRightPanelTabCalls).toEqual(["renders"]);
|
|
82
|
-
harness.unmount();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("does not bounce when the composition is a slideshow", () => {
|
|
86
|
-
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "slideshow" });
|
|
87
|
-
expect(harness.setRightPanelTabCalls).toEqual([]);
|
|
88
|
-
harness.unmount();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("does not bounce a tab other than 'slideshow'", () => {
|
|
92
|
-
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "renders" });
|
|
93
|
-
expect(harness.setRightPanelTabCalls).toEqual([]);
|
|
94
|
-
harness.unmount();
|
|
95
|
-
});
|
|
96
74
|
});
|
|
@@ -1,29 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo, type MutableRefObject } from "react";
|
|
2
2
|
import { SLIDESHOW_ISLAND_TYPE, slideshowIslandRegex } from "@hyperframes/core/slideshow";
|
|
3
3
|
import type { SceneInfo } from "../components/panels/SlideshowPanel";
|
|
4
4
|
import type { IframeWindow } from "../player/lib/playbackTypes";
|
|
5
|
-
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Derives whether the currently-edited composition is a slideshow (carries
|
|
9
8
|
* the slideshow JSON island — the same definitive marker the CLI's `present`
|
|
10
9
|
* command requires; it refuses to run without one) and the live scene list
|
|
11
|
-
* for the Slideshow panel
|
|
12
|
-
*
|
|
13
|
-
* while that tab was open) so the panel never shows a dangling active tab
|
|
14
|
-
* whose button is no longer even rendered.
|
|
15
|
-
*
|
|
16
|
-
* Extracted from StudioRightPanel to keep that file under the 600-LOC gate.
|
|
10
|
+
* for the Slideshow panel. Extracted from StudioRightPanels to keep that file
|
|
11
|
+
* under the 600-LOC gate.
|
|
17
12
|
*/
|
|
18
13
|
export function useSlideshowTabState(params: {
|
|
19
14
|
editingFileContent: string | null | undefined;
|
|
20
15
|
previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
|
|
21
16
|
refreshKey: number;
|
|
22
|
-
|
|
23
|
-
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
17
|
+
slideshowVisible: boolean;
|
|
24
18
|
}): { isSlideshowComposition: boolean; slideshowScenes: SceneInfo[] } {
|
|
25
|
-
const { editingFileContent, previewIframeRef, refreshKey,
|
|
26
|
-
params;
|
|
19
|
+
const { editingFileContent, previewIframeRef, refreshKey, slideshowVisible } = params;
|
|
27
20
|
|
|
28
21
|
// Presence-only (not full manifest validation): a malformed island should
|
|
29
22
|
// still surface the Slideshow tab so the user can see/fix it, rather than
|
|
@@ -49,13 +42,7 @@ export function useSlideshowTabState(params: {
|
|
|
49
42
|
return [];
|
|
50
43
|
}
|
|
51
44
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
-
}, [previewIframeRef,
|
|
53
|
-
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (rightPanelTab === "slideshow" && !isSlideshowComposition) {
|
|
56
|
-
setRightPanelTab("renders");
|
|
57
|
-
}
|
|
58
|
-
}, [rightPanelTab, isSlideshowComposition, setRightPanelTab]);
|
|
45
|
+
}, [previewIframeRef, slideshowVisible, refreshKey]);
|
|
59
46
|
|
|
60
47
|
return { isSlideshowComposition, slideshowScenes };
|
|
61
48
|
}
|
|
@@ -4,14 +4,12 @@ import React from "react";
|
|
|
4
4
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
7
|
-
import type {
|
|
7
|
+
import type { PanelId } from "../components/dock/panelRegistry";
|
|
8
8
|
import { makeSelection } from "./domSelectionTestHarness";
|
|
9
9
|
import { useInspectorState, type InspectorState } from "./useStudioContextValue";
|
|
10
10
|
|
|
11
11
|
interface HarnessProps {
|
|
12
|
-
|
|
13
|
-
rightInspectorPanes: RightInspectorPanes;
|
|
14
|
-
rightCollapsed: boolean;
|
|
12
|
+
rightPanel: PanelId | null;
|
|
15
13
|
isPlaying: boolean;
|
|
16
14
|
isGestureRecording: boolean;
|
|
17
15
|
domEditSelection: DomEditSelection | null;
|
|
@@ -22,9 +20,7 @@ function renderInspectorState(props: HarnessProps): InspectorState {
|
|
|
22
20
|
|
|
23
21
|
function Harness() {
|
|
24
22
|
state = useInspectorState(
|
|
25
|
-
props.
|
|
26
|
-
props.rightInspectorPanes,
|
|
27
|
-
props.rightCollapsed,
|
|
23
|
+
props.rightPanel,
|
|
28
24
|
props.isPlaying,
|
|
29
25
|
props.domEditSelection,
|
|
30
26
|
props.isGestureRecording,
|
|
@@ -42,9 +38,7 @@ function selectedProps(
|
|
|
42
38
|
): HarnessProps & { domEditSelection: DomEditSelection } {
|
|
43
39
|
const element = document.createElement("div");
|
|
44
40
|
return {
|
|
45
|
-
|
|
46
|
-
rightInspectorPanes: { layers: false, design: false },
|
|
47
|
-
rightCollapsed: true,
|
|
41
|
+
rightPanel: "renders",
|
|
48
42
|
isPlaying: false,
|
|
49
43
|
isGestureRecording: false,
|
|
50
44
|
domEditSelection: makeSelection("Selected", element),
|
|
@@ -78,16 +72,10 @@ describe("useInspectorState", () => {
|
|
|
78
72
|
it("keeps selected DOM bounds coupled to the inspector or variables panel", () => {
|
|
79
73
|
expect(renderInspectorState(selectedProps()).shouldShowSelectedDomBounds).toBe(false);
|
|
80
74
|
expect(
|
|
81
|
-
renderInspectorState(
|
|
82
|
-
selectedProps({
|
|
83
|
-
rightPanelTab: "design",
|
|
84
|
-
rightInspectorPanes: { layers: false, design: true },
|
|
85
|
-
}),
|
|
86
|
-
).shouldShowSelectedDomBounds,
|
|
75
|
+
renderInspectorState(selectedProps({ rightPanel: "design" })).shouldShowSelectedDomBounds,
|
|
87
76
|
).toBe(true);
|
|
88
77
|
expect(
|
|
89
|
-
renderInspectorState(selectedProps({
|
|
90
|
-
.shouldShowSelectedDomBounds,
|
|
78
|
+
renderInspectorState(selectedProps({ rightPanel: "variables" })).shouldShowSelectedDomBounds,
|
|
91
79
|
).toBe(true);
|
|
92
80
|
});
|
|
93
81
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useMemo, type DragEvent } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
3
|
import type { StudioContextValue } from "../contexts/StudioContext";
|
|
4
|
-
import type {
|
|
4
|
+
import type { PanelId } from "../components/dock/panelRegistry";
|
|
5
5
|
import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
|
|
6
6
|
import { usePlayerStore } from "../player";
|
|
7
7
|
|
|
@@ -66,33 +66,21 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export interface InspectorState {
|
|
69
|
-
layersPanelActive: boolean;
|
|
70
|
-
designPanelActive: boolean;
|
|
71
69
|
inspectorPanelActive: boolean;
|
|
72
|
-
inspectorButtonActive: boolean;
|
|
73
70
|
shouldShowMotionPath: boolean;
|
|
74
71
|
shouldShowSelectedDomBounds: boolean;
|
|
75
72
|
}
|
|
76
73
|
|
|
77
74
|
export function useInspectorState(
|
|
78
|
-
|
|
79
|
-
rightInspectorPanes: RightInspectorPanes,
|
|
80
|
-
rightCollapsed: boolean,
|
|
75
|
+
rightPanel: PanelId | null,
|
|
81
76
|
isPlaying: boolean,
|
|
82
77
|
domEditSelection: DomEditSelection | null,
|
|
83
78
|
isGestureRecording?: boolean,
|
|
84
79
|
): InspectorState {
|
|
85
|
-
// fallow-ignore-next-line complexity
|
|
86
80
|
return useMemo(() => {
|
|
87
|
-
const
|
|
88
|
-
const layersPanelActive = inspectorTabActive && rightInspectorPanes.layers;
|
|
89
|
-
const designPanelActive = inspectorTabActive && rightInspectorPanes.design;
|
|
90
|
-
const inspectorPanelActive = layersPanelActive || designPanelActive;
|
|
81
|
+
const inspectorPanelActive = rightPanel === "layers" || rightPanel === "design";
|
|
91
82
|
return {
|
|
92
|
-
layersPanelActive,
|
|
93
|
-
designPanelActive,
|
|
94
83
|
inspectorPanelActive,
|
|
95
|
-
inspectorButtonActive: !rightCollapsed && inspectorPanelActive,
|
|
96
84
|
// Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path
|
|
97
85
|
// handles ARE the arc-drag affordance, so gating them on an open Inspector
|
|
98
86
|
// would make keyframe path editing reachable only from a side panel.
|
|
@@ -102,18 +90,9 @@ export function useInspectorState(
|
|
|
102
90
|
// The Variables tab also works against the canvas selection (bind card),
|
|
103
91
|
// so the selection outline stays visible there too.
|
|
104
92
|
shouldShowSelectedDomBounds:
|
|
105
|
-
(inspectorPanelActive ||
|
|
106
|
-
!isPlaying &&
|
|
107
|
-
!isGestureRecording,
|
|
93
|
+
(inspectorPanelActive || rightPanel === "variables") && !isPlaying && !isGestureRecording,
|
|
108
94
|
};
|
|
109
|
-
}, [
|
|
110
|
-
rightPanelTab,
|
|
111
|
-
rightInspectorPanes,
|
|
112
|
-
rightCollapsed,
|
|
113
|
-
isPlaying,
|
|
114
|
-
isGestureRecording,
|
|
115
|
-
domEditSelection,
|
|
116
|
-
]);
|
|
95
|
+
}, [rightPanel, isPlaying, isGestureRecording, domEditSelection]);
|
|
117
96
|
}
|
|
118
97
|
|
|
119
98
|
/** Lets an OS file drop reach `onDrop` anywhere in the shell; the timeline shows its own landing preview. */
|