@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
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { marked } from "marked";
|
|
3
|
-
import DOMPurify from "dompurify";
|
|
4
|
-
import { SourceEditor } from "../editor/SourceEditor";
|
|
5
|
-
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
|
6
|
-
|
|
7
|
-
export interface SourceFile {
|
|
8
|
-
path: string;
|
|
9
|
-
label: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface StoryboardSourceEditorProps {
|
|
13
|
-
files: SourceFile[];
|
|
14
|
-
/** Called after a successful save so the Board can re-parse the updated file. */
|
|
15
|
-
onSaved: () => void;
|
|
16
|
-
/** Surfaces unsaved-edit state so the parent can guard the Board↔Source toggle. */
|
|
17
|
-
onDirtyChange?: (dirty: boolean) => void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const DISCARD_PROMPT = "Discard unsaved markdown changes?";
|
|
21
|
-
|
|
22
|
-
interface EditableFile {
|
|
23
|
-
content: string;
|
|
24
|
-
setContent: (next: string) => void;
|
|
25
|
-
dirty: boolean;
|
|
26
|
-
loading: boolean;
|
|
27
|
-
saving: boolean;
|
|
28
|
-
error: string | null;
|
|
29
|
-
save: () => void;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Load a project file's raw text and track edits + save state via the shared file manager. */
|
|
33
|
-
function useEditableFile(path: string, onSaved: () => void): EditableFile {
|
|
34
|
-
const { readProjectFile, writeProjectFile } = useFileManagerContext();
|
|
35
|
-
const [content, setContent] = useState("");
|
|
36
|
-
const [saved, setSaved] = useState("");
|
|
37
|
-
const [loading, setLoading] = useState(true);
|
|
38
|
-
const [saving, setSaving] = useState(false);
|
|
39
|
-
const [error, setError] = useState<string | null>(null);
|
|
40
|
-
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
if (!path) return;
|
|
43
|
-
let cancelled = false;
|
|
44
|
-
setLoading(true);
|
|
45
|
-
setError(null);
|
|
46
|
-
readProjectFile(path)
|
|
47
|
-
.then((text) => {
|
|
48
|
-
if (cancelled) return;
|
|
49
|
-
setContent(text);
|
|
50
|
-
setSaved(text);
|
|
51
|
-
})
|
|
52
|
-
.catch((err: unknown) => {
|
|
53
|
-
if (!cancelled) setError(err instanceof Error ? err.message : "failed to load file");
|
|
54
|
-
})
|
|
55
|
-
.finally(() => {
|
|
56
|
-
if (!cancelled) setLoading(false);
|
|
57
|
-
});
|
|
58
|
-
return () => {
|
|
59
|
-
cancelled = true;
|
|
60
|
-
};
|
|
61
|
-
}, [path, readProjectFile]);
|
|
62
|
-
|
|
63
|
-
const save = useCallback(() => {
|
|
64
|
-
if (saving) return; // coalesce a fast double Cmd+S into one PUT
|
|
65
|
-
setSaving(true);
|
|
66
|
-
setError(null);
|
|
67
|
-
writeProjectFile(path, content)
|
|
68
|
-
.then(() => {
|
|
69
|
-
setSaved(content);
|
|
70
|
-
onSaved();
|
|
71
|
-
})
|
|
72
|
-
.catch((err: unknown) => setError(err instanceof Error ? err.message : "failed to save"))
|
|
73
|
-
.finally(() => setSaving(false));
|
|
74
|
-
}, [writeProjectFile, path, content, onSaved, saving]);
|
|
75
|
-
|
|
76
|
-
return { content, setContent, dirty: content !== saved, loading, saving, error, save };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Preview links open in a new tab with the `window.opener` back-channel severed. */
|
|
80
|
-
function hardenLinks(node: Element): void {
|
|
81
|
-
if (node.tagName === "A" && node.hasAttribute("href")) {
|
|
82
|
-
node.setAttribute("target", "_blank");
|
|
83
|
-
node.setAttribute("rel", "noopener noreferrer");
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Render markdown to sanitized HTML, debounced so we don't re-parse on every keystroke. */
|
|
88
|
-
function useMarkdownPreview(source: string): string {
|
|
89
|
-
const [debounced, setDebounced] = useState(source);
|
|
90
|
-
const primed = useRef(false);
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
// Paint the first non-empty content immediately (no 200ms blank window after a file
|
|
93
|
-
// loads), exactly once, then debounce all subsequent keystrokes.
|
|
94
|
-
if (!primed.current && source !== "") {
|
|
95
|
-
primed.current = true;
|
|
96
|
-
setDebounced(source);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
const id = window.setTimeout(() => setDebounced(source), 200);
|
|
100
|
-
return () => window.clearTimeout(id);
|
|
101
|
-
}, [source]);
|
|
102
|
-
return useMemo(() => {
|
|
103
|
-
// `{ async: false }` pins the synchronous string return (no Promise union to narrow).
|
|
104
|
-
const html = marked.parse(debounced, { async: false });
|
|
105
|
-
// Scope the link-hardening hook to this call; `finally` guarantees removal even if
|
|
106
|
-
// `sanitize` throws, so the hook can never leak into other DOMPurify consumers.
|
|
107
|
-
DOMPurify.addHook("afterSanitizeAttributes", hardenLinks);
|
|
108
|
-
try {
|
|
109
|
-
return DOMPurify.sanitize(html);
|
|
110
|
-
} finally {
|
|
111
|
-
DOMPurify.removeHook("afterSanitizeAttributes");
|
|
112
|
-
}
|
|
113
|
-
}, [debounced]);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function isSaveShortcut(event: React.KeyboardEvent): boolean {
|
|
117
|
-
return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Minimal prose styling for the rendered preview (Tailwind doesn't style raw HTML).
|
|
121
|
-
const PREVIEW_PROSE =
|
|
122
|
-
"text-sm leading-relaxed text-neutral-300 " +
|
|
123
|
-
"[&_h1]:mb-2 [&_h1]:mt-5 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-neutral-100 " +
|
|
124
|
-
"[&_h2]:mb-1.5 [&_h2]:mt-5 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-neutral-100 " +
|
|
125
|
-
"[&_h3]:mt-4 [&_h3]:font-semibold [&_h3]:text-neutral-200 " +
|
|
126
|
-
"[&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 " +
|
|
127
|
-
"[&_code]:rounded [&_code]:bg-neutral-800 [&_code]:px-1 [&_code]:text-[0.85em] [&_code]:text-neutral-200 " +
|
|
128
|
-
"[&_pre]:my-3 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-neutral-900 [&_pre]:p-3 " +
|
|
129
|
-
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
|
|
130
|
-
"[&_hr]:my-4 [&_hr]:border-neutral-800 [&_a]:text-sky-400 [&_strong]:text-neutral-100 " +
|
|
131
|
-
"[&_img]:my-2 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded " +
|
|
132
|
-
"[&_table]:my-3 [&_th]:border [&_th]:border-neutral-800 [&_th]:px-2 [&_th]:py-1 " +
|
|
133
|
-
"[&_td]:border [&_td]:border-neutral-800 [&_td]:px-2 [&_td]:py-1";
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Raw markdown editor + live preview for the storyboard's canonical files
|
|
137
|
-
* (STORYBOARD.md / SCRIPT.md). Markdown stays the source of truth: saving writes
|
|
138
|
-
* the file and re-parses the Board. Deliberately raw (not WYSIWYG) so the
|
|
139
|
-
* structured frame fields can't be mangled; the preview is sanitized before render.
|
|
140
|
-
*/
|
|
141
|
-
// fallow-ignore-next-line complexity
|
|
142
|
-
export function StoryboardSourceEditor({
|
|
143
|
-
files,
|
|
144
|
-
onSaved,
|
|
145
|
-
onDirtyChange,
|
|
146
|
-
}: StoryboardSourceEditorProps) {
|
|
147
|
-
const [selected, setSelected] = useState(files[0]?.path ?? "");
|
|
148
|
-
// Reconcile against the current file list so a removed/renamed file can't strand the tab.
|
|
149
|
-
const activePath = files.some((f) => f.path === selected) ? selected : (files[0]?.path ?? "");
|
|
150
|
-
const file = useEditableFile(activePath, onSaved);
|
|
151
|
-
const previewHtml = useMarkdownPreview(file.content);
|
|
152
|
-
|
|
153
|
-
// Surface dirty state to the parent (guards the Board↔Source toggle) and warn
|
|
154
|
-
// on browser-level navigation while there are unsaved edits.
|
|
155
|
-
useEffect(() => onDirtyChange?.(file.dirty), [onDirtyChange, file.dirty]);
|
|
156
|
-
useEffect(() => {
|
|
157
|
-
if (!file.dirty) return;
|
|
158
|
-
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
159
|
-
event.preventDefault();
|
|
160
|
-
event.returnValue = "";
|
|
161
|
-
};
|
|
162
|
-
window.addEventListener("beforeunload", onBeforeUnload);
|
|
163
|
-
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
|
164
|
-
}, [file.dirty]);
|
|
165
|
-
|
|
166
|
-
// Switching files discards the in-memory buffer; confirm when there are unsaved edits.
|
|
167
|
-
const selectFile = (path: string) => {
|
|
168
|
-
if (path === activePath) return;
|
|
169
|
-
if (file.dirty && !window.confirm(DISCARD_PROMPT)) return;
|
|
170
|
-
setSelected(path);
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
return (
|
|
174
|
-
<div
|
|
175
|
-
className="flex flex-1 min-h-0 flex-col"
|
|
176
|
-
onKeyDown={(e) => {
|
|
177
|
-
if (!isSaveShortcut(e)) return;
|
|
178
|
-
e.preventDefault();
|
|
179
|
-
if (file.dirty && !file.saving) file.save();
|
|
180
|
-
}}
|
|
181
|
-
>
|
|
182
|
-
<div className="flex items-center gap-1 border-b border-neutral-800 px-4 py-2">
|
|
183
|
-
{files.map((f) => (
|
|
184
|
-
<button
|
|
185
|
-
key={f.path}
|
|
186
|
-
type="button"
|
|
187
|
-
onClick={() => selectFile(f.path)}
|
|
188
|
-
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
189
|
-
activePath === f.path
|
|
190
|
-
? "bg-neutral-800 text-neutral-100"
|
|
191
|
-
: "text-neutral-400 hover:text-neutral-200"
|
|
192
|
-
}`}
|
|
193
|
-
>
|
|
194
|
-
{f.label}
|
|
195
|
-
</button>
|
|
196
|
-
))}
|
|
197
|
-
<div className="ml-auto flex items-center gap-3">
|
|
198
|
-
{file.error && <span className="text-xs text-red-400">{file.error}</span>}
|
|
199
|
-
<span className="text-xs text-neutral-500">
|
|
200
|
-
{file.saving ? "Saving…" : file.dirty ? "Unsaved changes" : "Saved"}
|
|
201
|
-
</span>
|
|
202
|
-
<button
|
|
203
|
-
type="button"
|
|
204
|
-
onClick={file.save}
|
|
205
|
-
disabled={!file.dirty || file.saving}
|
|
206
|
-
className="rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-40"
|
|
207
|
-
>
|
|
208
|
-
Save
|
|
209
|
-
</button>
|
|
210
|
-
</div>
|
|
211
|
-
</div>
|
|
212
|
-
<div className="flex flex-1 min-h-0">
|
|
213
|
-
<div className="w-1/2 min-w-0 border-r border-neutral-800">
|
|
214
|
-
{file.loading ? (
|
|
215
|
-
<div className="p-4 text-sm text-neutral-500">Loading {activePath}…</div>
|
|
216
|
-
) : (
|
|
217
|
-
<SourceEditor
|
|
218
|
-
content={file.content}
|
|
219
|
-
language="markdown"
|
|
220
|
-
filePath={activePath}
|
|
221
|
-
onChange={file.setContent}
|
|
222
|
-
/>
|
|
223
|
-
)}
|
|
224
|
-
</div>
|
|
225
|
-
<div className="w-1/2 min-w-0 overflow-auto bg-neutral-950 px-6 py-4">
|
|
226
|
-
<div className={PREVIEW_PROSE} dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
|
227
|
-
</div>
|
|
228
|
-
</div>
|
|
229
|
-
</div>
|
|
230
|
-
);
|
|
231
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Explains the frame lifecycle: a frame advances outline → built → animated.
|
|
5
|
-
* Mirrors the status chips on each tile (shares FRAME_STATUS_META).
|
|
6
|
-
*/
|
|
7
|
-
export function StoryboardStatusLegend() {
|
|
8
|
-
return (
|
|
9
|
-
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-neutral-500">
|
|
10
|
-
<span className="uppercase tracking-wider text-neutral-600">Status</span>
|
|
11
|
-
{FRAME_STATUS_ORDER.map((status, i) => (
|
|
12
|
-
<span key={status} className="flex items-center gap-1.5">
|
|
13
|
-
{i > 0 && <span className="text-neutral-700">→</span>}
|
|
14
|
-
<span className={`h-2 w-2 rounded-full ${FRAME_STATUS_META[status].dotClass}`} />
|
|
15
|
-
<span className="font-medium text-neutral-300">{FRAME_STATUS_META[status].label}</span>
|
|
16
|
-
<span className="text-neutral-500">— {FRAME_STATUS_META[status].description}</span>
|
|
17
|
-
</span>
|
|
18
|
-
))}
|
|
19
|
-
</div>
|
|
20
|
-
);
|
|
21
|
-
}
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import { useState, type ReactNode } from "react";
|
|
2
|
-
import { Copy, Check } from "@phosphor-icons/react";
|
|
3
|
-
import { useStoryboard } from "../../hooks/useStoryboard";
|
|
4
|
-
import { useProjectSignaturePoll } from "../../hooks/useProjectSignaturePoll";
|
|
5
|
-
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
|
-
import { Button } from "../ui/Button";
|
|
7
|
-
import { StoryboardLoaded } from "./StoryboardLoaded";
|
|
8
|
-
|
|
9
|
-
export interface StoryboardViewProps {
|
|
10
|
-
projectId: string;
|
|
11
|
-
/** Select a composition in the timeline (used by the frame focus "Open in Preview"). */
|
|
12
|
-
onSelectComposition: (path: string) => void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Top-level storyboard stage. Replaces the timeline/preview when the view mode
|
|
17
|
-
* is `storyboard`. Handles the load states here; once a storyboard exists,
|
|
18
|
-
* {@link StoryboardLoaded} owns the Board ↔ Source experience.
|
|
19
|
-
*/
|
|
20
|
-
// fallow-ignore-next-line complexity
|
|
21
|
-
export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
|
|
22
|
-
const { data, loading, error, reload } = useStoryboard(projectId);
|
|
23
|
-
// Keep the board current while an agent writes to the project: when the
|
|
24
|
-
// project signature moves past the one `data` was loaded with, refetch. Also
|
|
25
|
-
// upgrades the empty state the moment STORYBOARD.md lands on disk.
|
|
26
|
-
useProjectSignaturePoll(projectId, data?.signature, reload);
|
|
27
|
-
|
|
28
|
-
if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
|
|
29
|
-
if (error) {
|
|
30
|
-
return (
|
|
31
|
-
<StoryboardFrame>
|
|
32
|
-
<Message tone="error">Couldn’t load the storyboard: {error}</Message>
|
|
33
|
-
<div className="flex justify-center">
|
|
34
|
-
<Button size="sm" variant="secondary" onClick={reload}>
|
|
35
|
-
Retry
|
|
36
|
-
</Button>
|
|
37
|
-
</div>
|
|
38
|
-
</StoryboardFrame>
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
if (!data) return <StoryboardFrame>{null}</StoryboardFrame>;
|
|
42
|
-
if (!data.exists) {
|
|
43
|
-
return (
|
|
44
|
-
<StoryboardFrame>
|
|
45
|
-
<EmptyState path={data.path} />
|
|
46
|
-
</StoryboardFrame>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return (
|
|
51
|
-
<StoryboardLoaded
|
|
52
|
-
projectId={projectId}
|
|
53
|
-
data={data}
|
|
54
|
-
reload={reload}
|
|
55
|
-
onSelectComposition={onSelectComposition}
|
|
56
|
-
/>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function StoryboardFrame({ children }: { children: ReactNode }) {
|
|
61
|
-
return (
|
|
62
|
-
<div className="flex-1 min-h-0 overflow-auto bg-neutral-950 text-neutral-200">
|
|
63
|
-
<div className="mx-auto max-w-[1400px] px-8 py-8">{children}</div>
|
|
64
|
-
</div>
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function Message({ children, tone = "muted" }: { children: ReactNode; tone?: "muted" | "error" }) {
|
|
69
|
-
return (
|
|
70
|
-
<div
|
|
71
|
-
className={`px-6 py-12 text-center text-sm ${
|
|
72
|
-
tone === "error" ? "text-red-400" : "text-neutral-500"
|
|
73
|
-
}`}
|
|
74
|
-
>
|
|
75
|
-
{children}
|
|
76
|
-
</div>
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function handoffPrompt(path: string): string {
|
|
81
|
-
return `Create a \`${path}\` at the project root to plan this video frame by frame.
|
|
82
|
-
|
|
83
|
-
Use this format:
|
|
84
|
-
|
|
85
|
-
---
|
|
86
|
-
format: 1920x1080
|
|
87
|
-
message: <the one-line takeaway of the video>
|
|
88
|
-
arc: <the narrative shape, e.g. Problem → Solution>
|
|
89
|
-
audience: <who it's for>
|
|
90
|
-
---
|
|
91
|
-
|
|
92
|
-
## Frame 1 — <title>
|
|
93
|
-
- duration: 5s
|
|
94
|
-
- transition_in: crossfade
|
|
95
|
-
- status: outline
|
|
96
|
-
- src: compositions/frames/01-<slug>.html
|
|
97
|
-
|
|
98
|
-
<A sentence or two: what's on screen and what the narration says.>
|
|
99
|
-
|
|
100
|
-
Add one \`## Frame N\` section per beat. Keep the arc tight.
|
|
101
|
-
|
|
102
|
-
Then run the review loop from the hyperframes skill (references/review-loop.md): present the plan as a proposal, offer wireframe sketches on this board, and build on the confirmed layouts.`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function EmptyState({ path }: { path: string }) {
|
|
106
|
-
const [copied, setCopied] = useState(false);
|
|
107
|
-
const prompt = handoffPrompt(path);
|
|
108
|
-
|
|
109
|
-
const onCopy = async () => {
|
|
110
|
-
if (await copyTextToClipboard(prompt)) {
|
|
111
|
-
setCopied(true);
|
|
112
|
-
window.setTimeout(() => setCopied(false), 2000);
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
return (
|
|
117
|
-
<div>
|
|
118
|
-
<div className="rounded-lg border border-dashed border-neutral-800 px-6 py-10 text-center">
|
|
119
|
-
<h2 className="text-base font-semibold text-neutral-300">No storyboard yet</h2>
|
|
120
|
-
<p className="mx-auto mt-2 max-w-md text-sm text-neutral-500">
|
|
121
|
-
Add a <code className="rounded bg-neutral-900 px-1 py-0.5 text-neutral-400">{path}</code>{" "}
|
|
122
|
-
at the project root to plan this video frame by frame. Hand this prompt to your coding
|
|
123
|
-
agent to scaffold it.
|
|
124
|
-
</p>
|
|
125
|
-
|
|
126
|
-
<div className="mx-auto mt-6 max-w-2xl overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900 text-left">
|
|
127
|
-
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
|
|
128
|
-
<span className="font-mono text-xs text-neutral-500">Prompt for your agent</span>
|
|
129
|
-
<Button
|
|
130
|
-
size="sm"
|
|
131
|
-
variant="secondary"
|
|
132
|
-
onClick={onCopy}
|
|
133
|
-
icon={copied ? <Check size={14} /> : <Copy size={14} />}
|
|
134
|
-
>
|
|
135
|
-
{copied ? "Copied" : "Copy prompt"}
|
|
136
|
-
</Button>
|
|
137
|
-
</div>
|
|
138
|
-
<pre className="max-h-64 overflow-auto px-4 py-3 font-mono text-xs leading-relaxed text-neutral-400 whitespace-pre-wrap">
|
|
139
|
-
{prompt}
|
|
140
|
-
</pre>
|
|
141
|
-
</div>
|
|
142
|
-
</div>
|
|
143
|
-
|
|
144
|
-
<SkeletonPreview />
|
|
145
|
-
</div>
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Faded placeholder of a filled board so landing here isn't a dead end —
|
|
150
|
-
* it previews the contact-sheet layout {@link StoryboardGrid} renders. */
|
|
151
|
-
function SkeletonPreview() {
|
|
152
|
-
return (
|
|
153
|
-
<div aria-hidden="true" className="mt-10 select-none opacity-40">
|
|
154
|
-
<div className="mb-4 text-center text-xs uppercase tracking-wide text-neutral-600">
|
|
155
|
-
Preview
|
|
156
|
-
</div>
|
|
157
|
-
<div className="grid gap-x-6 gap-y-8 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
|
|
158
|
-
{[0, 1, 2].map((i) => (
|
|
159
|
-
<div key={i} className="rounded-lg border border-neutral-800 bg-neutral-900/40 p-3">
|
|
160
|
-
<div className="aspect-video w-full rounded bg-neutral-800/60" />
|
|
161
|
-
<div className="mt-3 h-3 w-2/3 rounded bg-neutral-800/60" />
|
|
162
|
-
<div className="mt-2 h-2.5 w-full rounded bg-neutral-800/40" />
|
|
163
|
-
<div className="mt-1.5 h-2.5 w-4/5 rounded bg-neutral-800/40" />
|
|
164
|
-
</div>
|
|
165
|
-
))}
|
|
166
|
-
</div>
|
|
167
|
-
</div>
|
|
168
|
-
);
|
|
169
|
-
}
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
|
|
3
|
-
import React, { act } from "react";
|
|
4
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { ViewModeProvider, useViewModeState } from "../../contexts/ViewModeContext";
|
|
7
|
-
import { ViewModeToggle } from "../StudioHeader";
|
|
8
|
-
import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
|
|
9
|
-
|
|
10
|
-
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
-
|
|
12
|
-
vi.mock("../../contexts/FileManagerContext", () => ({
|
|
13
|
-
useFileManagerContext: () => ({
|
|
14
|
-
readProjectFile: vi.fn(),
|
|
15
|
-
writeProjectFile: vi.fn(),
|
|
16
|
-
}),
|
|
17
|
-
}));
|
|
18
|
-
|
|
19
|
-
vi.mock("../../utils/studioTelemetry", () => ({
|
|
20
|
-
trackStudioEvent: vi.fn(),
|
|
21
|
-
}));
|
|
22
|
-
|
|
23
|
-
vi.mock("./FramePoster", () => ({
|
|
24
|
-
FramePoster: () => <div>poster</div>,
|
|
25
|
-
posterTime: () => 0,
|
|
26
|
-
}));
|
|
27
|
-
|
|
28
|
-
const onSelectComposition = vi.fn();
|
|
29
|
-
const onNavigate = vi.fn();
|
|
30
|
-
|
|
31
|
-
function TestApp() {
|
|
32
|
-
const viewMode = useViewModeState();
|
|
33
|
-
return (
|
|
34
|
-
<ViewModeProvider value={viewMode}>
|
|
35
|
-
<span data-view-mode={viewMode.viewMode}>{viewMode.viewMode}</span>
|
|
36
|
-
<ViewModeToggle />
|
|
37
|
-
{viewMode.viewMode === "storyboard" && (
|
|
38
|
-
<StoryboardFrameFocus
|
|
39
|
-
projectId="project"
|
|
40
|
-
storyboardPath="STORYBOARD.md"
|
|
41
|
-
frame={{
|
|
42
|
-
index: 1,
|
|
43
|
-
number: 1,
|
|
44
|
-
title: "Opening",
|
|
45
|
-
status: "built",
|
|
46
|
-
src: "frames/01-opening.html",
|
|
47
|
-
srcExists: true,
|
|
48
|
-
voiceover: "Original voiceover",
|
|
49
|
-
narrative: "",
|
|
50
|
-
extra: {},
|
|
51
|
-
}}
|
|
52
|
-
frameCount={2}
|
|
53
|
-
onBack={vi.fn()}
|
|
54
|
-
onNavigate={onNavigate}
|
|
55
|
-
onSaved={vi.fn()}
|
|
56
|
-
onSelectComposition={onSelectComposition}
|
|
57
|
-
scriptExists={false}
|
|
58
|
-
commentDraft=""
|
|
59
|
-
onCommentDraftChange={vi.fn()}
|
|
60
|
-
pendingComment={null}
|
|
61
|
-
pendingCommentCount={0}
|
|
62
|
-
commentDraftCount={0}
|
|
63
|
-
commentsSubmitState="idle"
|
|
64
|
-
commentsSubmitError={null}
|
|
65
|
-
feedbackMessageCopied={false}
|
|
66
|
-
onFeedbackMessageCopied={vi.fn()}
|
|
67
|
-
onSaveFeedback={vi.fn()}
|
|
68
|
-
/>
|
|
69
|
-
)}
|
|
70
|
-
</ViewModeProvider>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function renderApp(): { host: HTMLDivElement; root: Root } {
|
|
75
|
-
const host = document.createElement("div");
|
|
76
|
-
document.body.append(host);
|
|
77
|
-
const root = createRoot(host);
|
|
78
|
-
act(() => root.render(<TestApp />));
|
|
79
|
-
return { host, root };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function makeVoiceoverDirty(host: HTMLElement): void {
|
|
83
|
-
const textarea = host.querySelector<HTMLTextAreaElement>(
|
|
84
|
-
'textarea[placeholder^="What the narrator says"]',
|
|
85
|
-
);
|
|
86
|
-
if (!textarea) throw new Error("voiceover textarea not rendered");
|
|
87
|
-
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
|
88
|
-
act(() => {
|
|
89
|
-
valueSetter?.call(textarea, "Changed voiceover");
|
|
90
|
-
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function clickButton(host: HTMLElement, label: string): void {
|
|
95
|
-
const button = [...host.querySelectorAll("button")].find(
|
|
96
|
-
(candidate) => candidate.textContent?.trim() === label,
|
|
97
|
-
);
|
|
98
|
-
if (!button) throw new Error(`button not found: ${label}`);
|
|
99
|
-
act(() => button.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function goBack(): Promise<void> {
|
|
103
|
-
await act(async () => {
|
|
104
|
-
window.history.back();
|
|
105
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
beforeEach(() => {
|
|
110
|
-
window.history.replaceState({ entry: "timeline" }, "", "/");
|
|
111
|
-
window.history.pushState({ entry: "storyboard" }, "", "/?view=storyboard");
|
|
112
|
-
onSelectComposition.mockReset();
|
|
113
|
-
onNavigate.mockReset();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
afterEach(() => {
|
|
117
|
-
document.body.innerHTML = "";
|
|
118
|
-
vi.restoreAllMocks();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe("dirty storyboard voiceover view-mode guard", () => {
|
|
122
|
-
it("guards the header Preview transition on decline and allows it on accept", () => {
|
|
123
|
-
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
|
124
|
-
const { host, root } = renderApp();
|
|
125
|
-
makeVoiceoverDirty(host);
|
|
126
|
-
|
|
127
|
-
clickButton(host, "Preview");
|
|
128
|
-
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
|
|
129
|
-
expect(confirm).toHaveBeenCalledWith("Discard unsaved voiceover changes?");
|
|
130
|
-
|
|
131
|
-
confirm.mockReturnValue(true);
|
|
132
|
-
clickButton(host, "Preview");
|
|
133
|
-
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
|
|
134
|
-
expect(onSelectComposition).not.toHaveBeenCalled();
|
|
135
|
-
act(() => root.unmount());
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("guards Open in Preview on decline and selects the frame only after accept", () => {
|
|
139
|
-
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
|
140
|
-
const { host, root } = renderApp();
|
|
141
|
-
makeVoiceoverDirty(host);
|
|
142
|
-
|
|
143
|
-
clickButton(host, "Open in Preview →");
|
|
144
|
-
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
|
|
145
|
-
expect(onSelectComposition).not.toHaveBeenCalled();
|
|
146
|
-
|
|
147
|
-
confirm.mockReturnValue(true);
|
|
148
|
-
clickButton(host, "Open in Preview →");
|
|
149
|
-
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
|
|
150
|
-
expect(onSelectComposition).toHaveBeenCalledWith("frames/01-opening.html");
|
|
151
|
-
act(() => root.unmount());
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("guards browser history transitions on decline and allows them on accept", async () => {
|
|
155
|
-
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
|
156
|
-
const { host, root } = renderApp();
|
|
157
|
-
makeVoiceoverDirty(host);
|
|
158
|
-
|
|
159
|
-
await goBack();
|
|
160
|
-
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
|
|
161
|
-
expect(window.location.search).toBe("?view=storyboard");
|
|
162
|
-
expect(window.history.state).toEqual({ entry: "storyboard" });
|
|
163
|
-
expect(confirm).toHaveBeenCalledWith("Discard unsaved voiceover changes?");
|
|
164
|
-
|
|
165
|
-
confirm.mockReturnValue(true);
|
|
166
|
-
await goBack();
|
|
167
|
-
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
|
|
168
|
-
expect(window.location.search).toBe("");
|
|
169
|
-
expect(window.history.state).toEqual({ entry: "timeline" });
|
|
170
|
-
act(() => root.unmount());
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
describe("storyboard frame keyboard navigation", () => {
|
|
175
|
-
it("leaves arrow keys with contenteditable text", () => {
|
|
176
|
-
const { host, root } = renderApp();
|
|
177
|
-
const editor = document.createElement("div");
|
|
178
|
-
editor.contentEditable = "true";
|
|
179
|
-
host.append(editor);
|
|
180
|
-
editor.focus();
|
|
181
|
-
|
|
182
|
-
act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })));
|
|
183
|
-
|
|
184
|
-
expect(onNavigate).not.toHaveBeenCalled();
|
|
185
|
-
act(() => root.unmount());
|
|
186
|
-
});
|
|
187
|
-
});
|