@hyperframes/studio 0.8.49 → 0.8.51
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-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
- package/dist/assets/index-C05SNrvY.js +360 -0
- package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
- package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
- package/dist/assets/index-DUR9qzQM.css +1 -0
- package/dist/index.d.ts +29 -16
- package/dist/index.html +2 -2
- package/dist/index.js +7283 -8606
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +11 -12
- package/src/App.tsx +132 -143
- package/src/captions/components/CaptionAnimationPanel.tsx +4 -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 +4 -0
- package/src/components/EditorShell.tsx +28 -7
- package/src/components/ExternalFileConflictBanner.tsx +16 -16
- package/src/components/LintModal.tsx +6 -16
- package/src/components/MediaPreview.tsx +2 -2
- package/src/components/SaveQueuePausedBanner.tsx +2 -2
- package/src/components/StudioHeader.tsx +2 -62
- package/src/components/StudioLeftSidebar.tsx +7 -4
- package/src/components/StudioOverlays.tsx +2 -6
- package/src/components/StudioRightPanel.tsx +4 -4
- package/src/components/StudioToast.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +7 -7
- package/src/components/dock/Dock.test.tsx +192 -0
- package/src/components/dock/Dock.tsx +264 -0
- package/src/components/dock/dock.css +23 -0
- package/src/components/dock/dockLayout.ts +58 -0
- package/src/components/dock/dockLayoutSchema.ts +66 -0
- package/src/components/dock/dockLayoutStore.ts +91 -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.tsx +3 -3
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/OffCanvasIndicators.tsx +1 -1
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- 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 +21 -0
- package/src/components/editor/SnapToolbar.tsx +27 -3
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/TopologyLens.tsx +4 -4
- 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 +9 -3
- package/src/components/editor/previewGuidesStore.ts +26 -0
- 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/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/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +12 -7
- package/src/components/nle/PreviewOverlays.tsx +6 -6
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +4 -2
- package/src/components/nle/TimelinePane.tsx +17 -3
- package/src/components/nle/TimelineResizeDivider.tsx +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/components/panels/SlideshowPanel.tsx +2 -2
- 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 +3 -3
- package/src/components/panels/VariablesValueControls.tsx +2 -2
- package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
- package/src/components/renders/RenderQueue.test.tsx +7 -0
- package/src/components/renders/RenderQueue.tsx +6 -6
- package/src/components/renders/RenderQueueItem.tsx +6 -6
- 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/CompositionsTab.tsx +5 -5
- package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
- package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
- package/src/components/sidebar/LeftSidebar.tsx +19 -5
- package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
- package/src/components/ui/Button.test.tsx +60 -0
- package/src/components/ui/Button.tsx +4 -4
- package/src/components/ui/SearchInput.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +1 -1
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -1
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useLintModal.test.tsx +65 -0
- package/src/hooks/useLintModal.ts +3 -0
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
- package/src/hooks/useSdkSession.ts +43 -10
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
- package/src/hooks/useTimelineAssetDropOps.ts +52 -11
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/main.tsx +9 -3
- package/src/player/components/AudioWaveform.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +2 -2
- package/src/player/components/ClipContextMenu.tsx +90 -10
- 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 +16 -15
- package/src/player/components/TimelineCanvas.tsx +45 -8
- 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/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- 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 +54 -1
- package/src/player/components/TimelineLanes.tsx +2 -5
- package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineRuler.tsx +1 -1
- package/src/player/components/TimelineShortcutHint.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
- package/src/player/components/TimelineTypes.ts +6 -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/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineCallbacks.ts +8 -8
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.ts +2 -2
- package/src/player/components/timelineClipDragPreview.ts +51 -27
- package/src/player/components/timelineClipDragTypes.ts +3 -0
- package/src/player/components/timelineDragDrop.test.tsx +92 -1
- package/src/player/components/timelineDragDrop.ts +82 -33
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- 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/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/trackHeaderLabelRows.tsx +4 -4
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
- package/src/player/components/useTimelineClipDrag.ts +30 -3
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +52 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +2 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -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/tailwind-preset.shared.js +3 -0
- package/src/styles/theme.css +213 -0
- package/src/styles/theme.test.ts +196 -0
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +34 -6
- package/src/utils/timelineDropTrackInsert.test.ts +134 -0
- package/src/utils/timelineDropTrackInsert.ts +83 -0
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/dist/assets/index-Bgmx2h7W.js +0 -441
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- 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/useProjectSignaturePoll.test.tsx +0 -135
- package/src/hooks/useProjectSignaturePoll.ts +0 -68
- package/src/hooks/useStoryboard.ts +0 -99
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -2,14 +2,23 @@ import { useCallback, useRef } from "react";
|
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type ClipboardPayload,
|
|
7
|
+
type TimelineClipboardClip,
|
|
8
|
+
ID_ATTR_RE,
|
|
9
|
+
deduplicateIds,
|
|
10
|
+
insertAsSibling,
|
|
11
|
+
} from "../utils/clipboardPayload";
|
|
6
12
|
import { collectHtmlIds } from "../utils/studioHelpers";
|
|
7
13
|
import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
|
|
14
|
+
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
8
15
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
9
16
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
10
17
|
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
11
18
|
import { findElementForSelection } from "../components/editor/domEditingElement";
|
|
12
|
-
import { readFileContent } from "./timelineEditingHelpers";
|
|
19
|
+
import { findTimelineElementInIframe, readFileContent } from "./timelineEditingHelpers";
|
|
20
|
+
import { buildTimelineElementKey } from "../player/lib/timelineElementHelpers";
|
|
21
|
+
import { timeRangesOverlap } from "../player/components/timelineCollision";
|
|
13
22
|
|
|
14
23
|
interface RecordEditInput {
|
|
15
24
|
label: string;
|
|
@@ -26,11 +35,34 @@ interface UseClipboardOptions {
|
|
|
26
35
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
27
36
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
28
37
|
reloadPreview: () => void;
|
|
29
|
-
|
|
38
|
+
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
30
39
|
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
|
|
31
40
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
/** The timeline element(s) a copy/cut/duplicate acts on: the multi-selection
|
|
44
|
+
* when one is active, else the single primary selection. */
|
|
45
|
+
function getSelectedElements(): TimelineElement[] {
|
|
46
|
+
const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
|
|
47
|
+
if (!selectedElementId) return [];
|
|
48
|
+
const ids = selectedElementIds.size > 1 ? selectedElementIds : new Set([selectedElementId]);
|
|
49
|
+
return elements.filter((el) => ids.has(el.key ?? el.id));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function elementKey(domId: string, sourceFile: string): string {
|
|
53
|
+
return buildTimelineElementKey({ id: domId, fallbackIndex: 0, domId, sourceFile });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "Paste clip" / "Paste clips" — the edit-history label, no count shown. */
|
|
57
|
+
function clipLabel(verb: string, clipCount: number): string {
|
|
58
|
+
return `${verb} ${clipCount > 1 ? "clips" : "clip"}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** "Pasted clip" / "Pasted 3 clips" — the toast message, count shown once plural. */
|
|
62
|
+
function clipToast(verbPast: string, clipCount: number): string {
|
|
63
|
+
return clipCount > 1 ? `${verbPast} ${clipCount} clips` : `${verbPast} clip`;
|
|
64
|
+
}
|
|
65
|
+
|
|
34
66
|
function getElementOuterHtml(
|
|
35
67
|
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
|
36
68
|
selection: DomEditSelection,
|
|
@@ -47,6 +79,94 @@ function getElementOuterHtml(
|
|
|
47
79
|
return findElementForSelection(doc, selection, activeCompositionPath)?.outerHTML ?? null;
|
|
48
80
|
}
|
|
49
81
|
|
|
82
|
+
export interface PlacedClip {
|
|
83
|
+
track: number;
|
|
84
|
+
start: number;
|
|
85
|
+
duration: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tracksOverlap(a: PlacedClip, b: PlacedClip): boolean {
|
|
89
|
+
return (
|
|
90
|
+
a.track === b.track &&
|
|
91
|
+
timeRangesOverlap(a.start, a.start + a.duration, b.start, b.start + b.duration)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** CapCut: keeps the preferred track if free at the new time, else the next
|
|
96
|
+
* unused track index. Exported so placement is tested with values directly. */
|
|
97
|
+
export function resolveFreeTrack(preferred: PlacedClip, taken: readonly PlacedClip[]): number {
|
|
98
|
+
if (!taken.some((t) => tracksOverlap(preferred, t))) return preferred.track;
|
|
99
|
+
const maxTrack = taken.reduce((max, t) => Math.max(max, t.track), preferred.track);
|
|
100
|
+
return maxTrack + 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Strips data-hf-id from the root and every descendant so a clone re-mints
|
|
104
|
+
* its own. DOMParser, not a bracket-scoped regex, so a `>` inside an earlier
|
|
105
|
+
* attribute value or a single-quoted id can't defeat the strip. */
|
|
106
|
+
function stripHfIds(html: string, parser: DOMParser): string {
|
|
107
|
+
const root = parser.parseFromString(html, "text/html").body.firstElementChild;
|
|
108
|
+
if (!root) {
|
|
109
|
+
// A tag the HTML parser hoists out of <body> (title/meta/style/base/link)
|
|
110
|
+
// never reaches the walk above — fall back to a direct strip so
|
|
111
|
+
// data-hf-id still can't survive, even though no real clip root is one
|
|
112
|
+
// of these tags today.
|
|
113
|
+
return html.replace(/\sdata-hf-id=("[^"]*"|'[^']*')/g, "");
|
|
114
|
+
}
|
|
115
|
+
root.querySelectorAll("[data-hf-id]").forEach((el) => el.removeAttribute("data-hf-id"));
|
|
116
|
+
root.removeAttribute("data-hf-id");
|
|
117
|
+
return root.outerHTML;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Shared insertion path for paste and duplicate, anchored at the playhead or
|
|
121
|
+
* the selection's end respectively. Returns the final ids so the caller can
|
|
122
|
+
* select what it just placed, and the furthest end any clip lands at so the
|
|
123
|
+
* caller can grow the root composition's duration to cover it. */
|
|
124
|
+
export function pasteTimelineClips(
|
|
125
|
+
content: string,
|
|
126
|
+
clips: readonly TimelineClipboardClip[],
|
|
127
|
+
anchorTime: number,
|
|
128
|
+
liveElements: readonly TimelineElement[],
|
|
129
|
+
): { content: string; ids: string[]; requiredEnd: number } {
|
|
130
|
+
const groupMinStart = Math.min(...clips.map((c) => c.start));
|
|
131
|
+
let existingIds = collectHtmlIds(content);
|
|
132
|
+
const taken: PlacedClip[] = liveElements.map((el) => ({
|
|
133
|
+
track: el.authoredTrack ?? el.track,
|
|
134
|
+
start: el.start,
|
|
135
|
+
duration: el.duration,
|
|
136
|
+
}));
|
|
137
|
+
const ids: string[] = [];
|
|
138
|
+
let result = content;
|
|
139
|
+
let requiredEnd = 0;
|
|
140
|
+
const domParser = new DOMParser();
|
|
141
|
+
for (const clip of clips) {
|
|
142
|
+
const stripped = stripHfIds(clip.html, domParser);
|
|
143
|
+
const deduped = deduplicateIds(stripped, existingIds);
|
|
144
|
+
existingIds = existingIds.concat(collectHtmlIds(deduped));
|
|
145
|
+
const newStart = anchorTime + (clip.start - groupMinStart);
|
|
146
|
+
const newTrack = resolveFreeTrack(
|
|
147
|
+
{ track: clip.track, start: newStart, duration: clip.duration },
|
|
148
|
+
taken,
|
|
149
|
+
);
|
|
150
|
+
taken.push({ track: newTrack, start: newStart, duration: clip.duration });
|
|
151
|
+
requiredEnd = Math.max(requiredEnd, newStart + clip.duration);
|
|
152
|
+
|
|
153
|
+
// Only rewrite the outermost opening tag. The non-global regex matches
|
|
154
|
+
// the first occurrence, which is always in the root tag since outerHTML
|
|
155
|
+
// starts with it. Nested clips keep their own timing and track.
|
|
156
|
+
const rootTagEnd = deduped.indexOf(">");
|
|
157
|
+
const rootTag = rootTagEnd >= 0 ? deduped.slice(0, rootTagEnd + 1) : deduped;
|
|
158
|
+
const patchedRootTag = rootTag
|
|
159
|
+
.replace(/data-start="[^"]*"/, `data-start="${formatTimelineAttributeNumber(newStart)}"`)
|
|
160
|
+
.replace(/data-track-index="[^"]*"/, `data-track-index="${newTrack}"`);
|
|
161
|
+
const withPatched = patchedRootTag + deduped.slice(rootTagEnd + 1);
|
|
162
|
+
result = insertTimelineAssetIntoSource(result, withPatched);
|
|
163
|
+
|
|
164
|
+
const id = patchedRootTag.match(ID_ATTR_RE)?.[1];
|
|
165
|
+
if (id) ids.push(id);
|
|
166
|
+
}
|
|
167
|
+
return { content: result, ids, requiredEnd };
|
|
168
|
+
}
|
|
169
|
+
|
|
50
170
|
export function useClipboard({
|
|
51
171
|
projectId,
|
|
52
172
|
activeCompPath,
|
|
@@ -55,7 +175,7 @@ export function useClipboard({
|
|
|
55
175
|
writeProjectFile,
|
|
56
176
|
recordEdit,
|
|
57
177
|
reloadPreview,
|
|
58
|
-
|
|
178
|
+
handleTimelineElementsDelete,
|
|
59
179
|
handleDomEditElementDelete,
|
|
60
180
|
previewIframeRef,
|
|
61
181
|
}: UseClipboardOptions) {
|
|
@@ -63,47 +183,50 @@ export function useClipboard({
|
|
|
63
183
|
const projectIdRef = useRef(projectId);
|
|
64
184
|
projectIdRef.current = projectId;
|
|
65
185
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!element) return false;
|
|
76
|
-
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
77
|
-
|
|
78
|
-
let html: string | null = null;
|
|
79
|
-
try {
|
|
80
|
-
const doc = previewIframeRef.current?.contentDocument;
|
|
81
|
-
if (doc) {
|
|
82
|
-
html =
|
|
83
|
-
findElementForSelection(
|
|
84
|
-
doc,
|
|
85
|
-
{
|
|
86
|
-
hfId: element.hfId,
|
|
87
|
-
id: element.domId,
|
|
88
|
-
selector: element.selector,
|
|
89
|
-
selectorIndex: element.selectorIndex,
|
|
90
|
-
sourceFile: targetPath,
|
|
91
|
-
},
|
|
92
|
-
activeCompPath,
|
|
93
|
-
)?.outerHTML ?? null;
|
|
94
|
-
}
|
|
95
|
-
} catch {
|
|
96
|
-
// cross-origin frame
|
|
97
|
-
}
|
|
186
|
+
// Resolved through findTimelineElementInIframe, the same composition-aware
|
|
187
|
+
// lookup every other timeline editor uses — unlike findElementForSelection,
|
|
188
|
+
// it can address a composition-instance clip's root.
|
|
189
|
+
const collectSelectedClips = useCallback((): {
|
|
190
|
+
elements: TimelineElement[];
|
|
191
|
+
clips: TimelineClipboardClip[];
|
|
192
|
+
} | null => {
|
|
193
|
+
const selected = getSelectedElements();
|
|
194
|
+
if (selected.length === 0) return null;
|
|
98
195
|
|
|
196
|
+
const clips: TimelineClipboardClip[] = [];
|
|
197
|
+
for (const element of selected) {
|
|
198
|
+
const html =
|
|
199
|
+
findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath)?.outerHTML ??
|
|
200
|
+
null;
|
|
99
201
|
if (!html) {
|
|
100
|
-
showToast(
|
|
101
|
-
return
|
|
202
|
+
showToast(`Unable to copy "${element.label ?? element.id}".`, "info");
|
|
203
|
+
return null;
|
|
102
204
|
}
|
|
205
|
+
clips.push({
|
|
206
|
+
html,
|
|
207
|
+
start: element.start,
|
|
208
|
+
duration: element.duration,
|
|
209
|
+
// authoredTrack, not track: `track` can be a display-lane number
|
|
210
|
+
// remapped by normalizeToZones, and writing THAT into data-track-index
|
|
211
|
+
// re-targets the wrong track in the sparse authored file.
|
|
212
|
+
track: element.authoredTrack ?? element.track,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return { elements: selected, clips };
|
|
216
|
+
}, [activeCompPath, previewIframeRef, showToast]);
|
|
103
217
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
218
|
+
// Two independent copy modes (timeline clip vs DOM element) in one handler.
|
|
219
|
+
// fallow-ignore-next-line complexity
|
|
220
|
+
const handleCopy = useCallback((): boolean => {
|
|
221
|
+
if (usePlayerStore.getState().selectedElementId) {
|
|
222
|
+
const result = collectSelectedClips();
|
|
223
|
+
if (!result) return false;
|
|
224
|
+
const targetPath = result.elements[0]?.sourceFile || activeCompPath || "index.html";
|
|
225
|
+
clipboardRef.current = { kind: "timeline-clip", clips: result.clips, sourceFile: targetPath };
|
|
226
|
+
showToast(
|
|
227
|
+
result.clips.length > 1 ? `Copied ${result.clips.length} clips` : "Copied clip",
|
|
228
|
+
"info",
|
|
229
|
+
);
|
|
107
230
|
return true;
|
|
108
231
|
}
|
|
109
232
|
|
|
@@ -116,21 +239,23 @@ export function useClipboard({
|
|
|
116
239
|
return false;
|
|
117
240
|
}
|
|
118
241
|
const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
|
|
119
|
-
|
|
242
|
+
clipboardRef.current = {
|
|
120
243
|
kind: "dom-element",
|
|
121
244
|
html,
|
|
122
245
|
sourceFile: targetPath,
|
|
123
246
|
originSelector: domSelection.selector,
|
|
124
247
|
originSelectorIndex: domSelection.selectorIndex,
|
|
125
248
|
};
|
|
126
|
-
clipboardRef.current = payload;
|
|
127
249
|
showToast("Copied element", "info");
|
|
128
250
|
return true;
|
|
129
251
|
}
|
|
130
252
|
|
|
253
|
+
showToast("Nothing selected to copy.", "info");
|
|
131
254
|
return false;
|
|
132
|
-
}, [activeCompPath, domEditSelectionRef, previewIframeRef, showToast]);
|
|
255
|
+
}, [activeCompPath, collectSelectedClips, domEditSelectionRef, previewIframeRef, showToast]);
|
|
133
256
|
|
|
257
|
+
// Two independent paste modes (timeline clip vs DOM element) behind one guarded save.
|
|
258
|
+
// fallow-ignore-next-line complexity
|
|
134
259
|
const handlePaste = useCallback(async () => {
|
|
135
260
|
const payload = clipboardRef.current;
|
|
136
261
|
if (!payload) {
|
|
@@ -143,24 +268,19 @@ export function useClipboard({
|
|
|
143
268
|
const targetPath = activeCompPath || "index.html";
|
|
144
269
|
try {
|
|
145
270
|
const originalContent = await readFileContent(pid, targetPath);
|
|
146
|
-
const existingIds = collectHtmlIds(originalContent);
|
|
147
|
-
const deduped = deduplicateIds(payload.html, existingIds);
|
|
148
|
-
|
|
149
271
|
let patchedContent: string;
|
|
272
|
+
let pastedIds: string[] = [];
|
|
273
|
+
|
|
150
274
|
if (payload.kind === "timeline-clip") {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
/data-start="[^"]*"/,
|
|
159
|
-
`data-start="${formatTimelineAttributeNumber(currentTime)}"`,
|
|
160
|
-
);
|
|
161
|
-
const withNewStart = patchedRootTag + deduped.slice(rootTagEnd + 1);
|
|
162
|
-
patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart);
|
|
275
|
+
const { currentTime, elements } = usePlayerStore.getState();
|
|
276
|
+
const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
|
|
277
|
+
// A clip pasted past the current composition end would exist in the
|
|
278
|
+
// file but never appear on the timeline or in playback/export (the
|
|
279
|
+
// root's data-duration is what actually bounds the render).
|
|
280
|
+
patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
281
|
+
pastedIds = pasted.ids;
|
|
163
282
|
} else {
|
|
283
|
+
const deduped = deduplicateIds(payload.html, collectHtmlIds(originalContent));
|
|
164
284
|
patchedContent = insertAsSibling(
|
|
165
285
|
originalContent,
|
|
166
286
|
deduped,
|
|
@@ -169,9 +289,14 @@ export function useClipboard({
|
|
|
169
289
|
);
|
|
170
290
|
}
|
|
171
291
|
|
|
292
|
+
const label =
|
|
293
|
+
payload.kind === "timeline-clip"
|
|
294
|
+
? clipLabel("Paste", payload.clips.length)
|
|
295
|
+
: "Paste element";
|
|
296
|
+
|
|
172
297
|
await saveProjectFilesWithHistory({
|
|
173
298
|
projectId: pid,
|
|
174
|
-
label
|
|
299
|
+
label,
|
|
175
300
|
kind: "timeline" as EditHistoryKind,
|
|
176
301
|
files: { [targetPath]: patchedContent },
|
|
177
302
|
readFile: async () => originalContent,
|
|
@@ -179,25 +304,92 @@ export function useClipboard({
|
|
|
179
304
|
recordEdit,
|
|
180
305
|
});
|
|
181
306
|
|
|
307
|
+
// CapCut: the pasted clip(s) become the selection; the playhead does not
|
|
308
|
+
// move. reloadPreview is a bare refresh-key bump with no selection
|
|
309
|
+
// snapshot of its own; calling setSelection before it is what makes the
|
|
310
|
+
// post-reload store state land on the pasted ids instead of stale ones.
|
|
311
|
+
if (pastedIds.length > 0) {
|
|
312
|
+
usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
|
|
313
|
+
}
|
|
182
314
|
reloadPreview();
|
|
183
|
-
showToast(
|
|
315
|
+
showToast(
|
|
316
|
+
payload.kind === "timeline-clip"
|
|
317
|
+
? clipToast("Pasted", payload.clips.length)
|
|
318
|
+
: "Pasted element",
|
|
319
|
+
"info",
|
|
320
|
+
);
|
|
184
321
|
} catch (error) {
|
|
185
322
|
const message = error instanceof Error ? error.message : "Failed to paste";
|
|
186
323
|
showToast(message);
|
|
187
324
|
}
|
|
188
325
|
}, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]);
|
|
189
326
|
|
|
327
|
+
// Duplicates the current selection in place, immediately after it, without
|
|
328
|
+
// touching the clipboard — a pending copy must survive a Cmd+D. Shares
|
|
329
|
+
// pasteTimelineClips with handlePaste; only the anchor and clip source
|
|
330
|
+
// differ (the selection's own end here, the playhead there).
|
|
331
|
+
const handleDuplicate = useCallback(async (): Promise<boolean> => {
|
|
332
|
+
const result = collectSelectedClips();
|
|
333
|
+
if (!result) return false;
|
|
334
|
+
const pid = projectIdRef.current;
|
|
335
|
+
if (!pid) return false;
|
|
336
|
+
|
|
337
|
+
const { elements, clips } = result;
|
|
338
|
+
const targetPath = elements[0]?.sourceFile || activeCompPath || "index.html";
|
|
339
|
+
const anchorTime = Math.max(...elements.map((el) => el.start + el.duration));
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const originalContent = await readFileContent(pid, targetPath);
|
|
343
|
+
const liveElements = usePlayerStore.getState().elements;
|
|
344
|
+
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
|
|
345
|
+
const patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
346
|
+
const ids = pasted.ids;
|
|
347
|
+
|
|
348
|
+
await saveProjectFilesWithHistory({
|
|
349
|
+
projectId: pid,
|
|
350
|
+
label: clipLabel("Duplicate", clips.length),
|
|
351
|
+
kind: "timeline" as EditHistoryKind,
|
|
352
|
+
files: { [targetPath]: patchedContent },
|
|
353
|
+
readFile: async () => originalContent,
|
|
354
|
+
writeFile: writeProjectFile,
|
|
355
|
+
recordEdit,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// The duplicate becomes the selection, mirroring CapCut's own paste
|
|
359
|
+
// convention — there is no CapCut Duplicate to match directly (Cmd+D is
|
|
360
|
+
// a no-op there); this is our own choice for consistency with paste.
|
|
361
|
+
// Select before reloading, same reason as handlePaste above.
|
|
362
|
+
if (ids.length > 0) {
|
|
363
|
+
usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
|
|
364
|
+
}
|
|
365
|
+
reloadPreview();
|
|
366
|
+
showToast(clipToast("Duplicated", clips.length), "info");
|
|
367
|
+
return true;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
const message = error instanceof Error ? error.message : "Failed to duplicate";
|
|
370
|
+
showToast(message);
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}, [
|
|
374
|
+
activeCompPath,
|
|
375
|
+
collectSelectedClips,
|
|
376
|
+
recordEdit,
|
|
377
|
+
reloadPreview,
|
|
378
|
+
showToast,
|
|
379
|
+
writeProjectFile,
|
|
380
|
+
]);
|
|
381
|
+
|
|
190
382
|
const handleCut = useCallback(async (): Promise<boolean> => {
|
|
383
|
+
const selected = getSelectedElements();
|
|
191
384
|
const copied = handleCopy();
|
|
192
385
|
if (!copied) return false;
|
|
193
386
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
387
|
+
if (selected.length > 0) {
|
|
388
|
+
// One call for the whole selection, not one per element: the batched
|
|
389
|
+
// delete writes and records history once, so a multi-clip Cmd+X undoes
|
|
390
|
+
// in a single Cmd+Z instead of needing one per clip.
|
|
391
|
+
await handleTimelineElementsDelete(selected);
|
|
392
|
+
return true;
|
|
201
393
|
}
|
|
202
394
|
|
|
203
395
|
const domSelection = domEditSelectionRef.current;
|
|
@@ -206,7 +398,9 @@ export function useClipboard({
|
|
|
206
398
|
return true;
|
|
207
399
|
}
|
|
208
400
|
return true;
|
|
209
|
-
}, [handleCopy, domEditSelectionRef,
|
|
401
|
+
}, [handleCopy, domEditSelectionRef, handleTimelineElementsDelete, handleDomEditElementDelete]);
|
|
402
|
+
|
|
403
|
+
const canPaste = useCallback(() => clipboardRef.current !== null, []);
|
|
210
404
|
|
|
211
|
-
return { handleCopy, handlePaste, handleCut };
|
|
405
|
+
return { handleCopy, handlePaste, handleCut, handleDuplicate, canPaste };
|
|
212
406
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useLintModal } from "./useLintModal";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
let root: Root | null = null;
|
|
11
|
+
let latest: ReturnType<typeof useLintModal> | null = null;
|
|
12
|
+
|
|
13
|
+
function Probe() {
|
|
14
|
+
latest = useLintModal("demo");
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
if (root) act(() => root?.unmount());
|
|
20
|
+
root = null;
|
|
21
|
+
latest = null;
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function mountWithLint(response: () => Promise<unknown>) {
|
|
26
|
+
vi.stubGlobal("fetch", vi.fn(response));
|
|
27
|
+
root = createRoot(document.createElement("div"));
|
|
28
|
+
await act(async () => root?.render(<Probe />));
|
|
29
|
+
await act(async () => {
|
|
30
|
+
await latest?.handleLint();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lintResponse = (findings: Array<{ severity: string; message: string }>) => async () => ({
|
|
35
|
+
json: async () => ({ findings }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("useLintModal hasLintError", () => {
|
|
39
|
+
it("is true when a finding is an error and false for warnings only", async () => {
|
|
40
|
+
await mountWithLint(lintResponse([{ severity: "error", message: "nested" }]));
|
|
41
|
+
expect(latest?.hasLintError).toBe(true);
|
|
42
|
+
act(() => root?.unmount());
|
|
43
|
+
root = null;
|
|
44
|
+
await mountWithLint(lintResponse([{ severity: "warning", message: "soft" }]));
|
|
45
|
+
expect(latest?.hasLintError).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is true for a mixed error and warning list", async () => {
|
|
49
|
+
await mountWithLint(
|
|
50
|
+
lintResponse([
|
|
51
|
+
{ severity: "warning", message: "soft" },
|
|
52
|
+
{ severity: "error", message: "nested" },
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
expect(latest?.hasLintError).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is true when the manual lint itself fails, matching the badge count", async () => {
|
|
59
|
+
await mountWithLint(async () => {
|
|
60
|
+
throw new Error("offline");
|
|
61
|
+
});
|
|
62
|
+
expect(latest?.lintModal).toHaveLength(1);
|
|
63
|
+
expect(latest?.hasLintError).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -96,6 +96,8 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
|
|
|
96
96
|
|
|
97
97
|
const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
|
|
98
98
|
const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
|
|
99
|
+
// Reads the list the badge count reads: the manual result when present, else the background one.
|
|
100
|
+
const hasLintError = (lintModal ?? backgroundFindings).some((f) => f.severity === "error");
|
|
99
101
|
|
|
100
102
|
// Sync lint findings directly to the player store — eliminates the
|
|
101
103
|
// mirroring useEffect that was previously in App.tsx.
|
|
@@ -111,5 +113,6 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
|
|
|
111
113
|
backgroundFindings,
|
|
112
114
|
findingsByElement,
|
|
113
115
|
findingsByFile,
|
|
116
|
+
hasLintError,
|
|
114
117
|
};
|
|
115
118
|
}
|
|
@@ -24,7 +24,6 @@ const rootElement: TimelineElement = {
|
|
|
24
24
|
track: 0,
|
|
25
25
|
domId: "root-clip",
|
|
26
26
|
sourceFile: ROOT_FILE,
|
|
27
|
-
timingSource: "authored",
|
|
28
27
|
};
|
|
29
28
|
|
|
30
29
|
// An expanded sub-comp child: `start` is in MASTER coordinates (offset by the
|
|
@@ -38,7 +37,6 @@ const expandedChild: TimelineElement = {
|
|
|
38
37
|
track: 1,
|
|
39
38
|
domId: "child-clip",
|
|
40
39
|
sourceFile: SUBCOMP_FILE,
|
|
41
|
-
timingSource: "authored",
|
|
42
40
|
expandedParentStart: 2,
|
|
43
41
|
};
|
|
44
42
|
|
|
@@ -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
|
|