@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
|
@@ -70,6 +70,9 @@ interface RenderLanesOptions {
|
|
|
70
70
|
draggedClip?: DraggedClipState | null;
|
|
71
71
|
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
72
72
|
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
73
|
+
hoveredClip?: string | null;
|
|
74
|
+
renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
|
|
75
|
+
snapGuide?: { time: number; type: "beat" | "clip-edge" | "playhead" } | null;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
function renderLanes(options: RenderLanesOptions = {}): {
|
|
@@ -135,9 +138,11 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
135
138
|
laneCounts={laneCounts}
|
|
136
139
|
selectedElementId={null}
|
|
137
140
|
selectedElementIds={next.selectedElementIds ?? new Set()}
|
|
138
|
-
hoveredClip={null}
|
|
141
|
+
hoveredClip={next.hoveredClip ?? null}
|
|
142
|
+
renderClipContent={next.renderClipContent}
|
|
139
143
|
draggedClip={next.draggedClip ?? null}
|
|
140
144
|
draggedElement={null}
|
|
145
|
+
snapGuide={next.snapGuide ?? null}
|
|
141
146
|
multiDragPreview={next.multiDragPreview ?? null}
|
|
142
147
|
blockedClipRef={createRef<BlockedClipState | null>()}
|
|
143
148
|
suppressClickRef={{ current: false }}
|
|
@@ -176,6 +181,36 @@ function visibilityLabels(host: HTMLElement): (string | null)[] {
|
|
|
176
181
|
);
|
|
177
182
|
}
|
|
178
183
|
|
|
184
|
+
/** The beat guide's own highlight div, keyed by the green glow every other beat lacks. */
|
|
185
|
+
function beatHighlight(host: HTMLElement): HTMLElement | undefined {
|
|
186
|
+
return Array.from(host.querySelectorAll("div")).find((div) =>
|
|
187
|
+
(div.style.boxShadow ?? "").includes("34,197,94"),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
describe("TimelineLanes beat guide", () => {
|
|
192
|
+
it("draws the beat highlight from snapGuide, not from the stale draggedClip prop", () => {
|
|
193
|
+
const view = renderLanes({
|
|
194
|
+
elements: [element("clip-a", TRACK_A)],
|
|
195
|
+
snapGuide: { time: 1.5, type: "beat" },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(beatHighlight(view.host)?.style.left).toBe("150px");
|
|
199
|
+
act(() => view.root.unmount());
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("clears the highlight once the trim it belonged to ends", () => {
|
|
203
|
+
const view = renderLanes({
|
|
204
|
+
elements: [element("clip-a", TRACK_A)],
|
|
205
|
+
snapGuide: { time: 1.5, type: "beat" },
|
|
206
|
+
});
|
|
207
|
+
view.rerender({ elements: [element("clip-a", TRACK_A)], snapGuide: null });
|
|
208
|
+
|
|
209
|
+
expect(beatHighlight(view.host)).toBeUndefined();
|
|
210
|
+
act(() => view.root.unmount());
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
179
214
|
describe("TimelineLanes track numbering", () => {
|
|
180
215
|
// Screen readers literally announced "Hide track 0.16666666666666666".
|
|
181
216
|
it("numbers tracks contiguously from 1 regardless of the fractional sort keys", () => {
|
|
@@ -395,3 +430,21 @@ describe("TimelineLanes selection", () => {
|
|
|
395
430
|
act(() => view.root.unmount());
|
|
396
431
|
});
|
|
397
432
|
});
|
|
433
|
+
|
|
434
|
+
describe("TimelineLanes clip thumbnails", () => {
|
|
435
|
+
it("asks for the same frames at rest, hovered and selected", () => {
|
|
436
|
+
const rich: unknown[] = [];
|
|
437
|
+
const renderClipContent = vi.fn(
|
|
438
|
+
(_el: TimelineElement, _style: unknown, context: { rich: boolean }) => {
|
|
439
|
+
rich.push(context.rich);
|
|
440
|
+
return null;
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
const elements = [element("clip-a", TRACK_A)];
|
|
444
|
+
const view = renderLanes({ elements, renderClipContent });
|
|
445
|
+
view.rerender({ elements, renderClipContent, hoveredClip: "clip-a" });
|
|
446
|
+
view.rerender({ elements, renderClipContent, selectedElementIds: new Set(["clip-a"]) });
|
|
447
|
+
expect(new Set(rich)).toEqual(new Set([false]));
|
|
448
|
+
act(() => view.root.unmount());
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -59,6 +59,7 @@ export function TimelineLanes({
|
|
|
59
59
|
hoveredClip,
|
|
60
60
|
draggedClip,
|
|
61
61
|
draggedElement,
|
|
62
|
+
snapGuide,
|
|
62
63
|
multiDragPreview,
|
|
63
64
|
blockedClipRef,
|
|
64
65
|
suppressClickRef,
|
|
@@ -331,11 +332,7 @@ export function TimelineLanes({
|
|
|
331
332
|
beatTimes={beatAnalysis?.beatTimes}
|
|
332
333
|
beatStrengths={beatAnalysis?.beatStrengths}
|
|
333
334
|
pps={pps}
|
|
334
|
-
highlightTime={
|
|
335
|
-
draggedClip?.started && draggedClip.snapType === "beat"
|
|
336
|
-
? draggedClip.snapTime
|
|
337
|
-
: null
|
|
338
|
-
}
|
|
335
|
+
highlightTime={snapGuide?.type === "beat" ? snapGuide.time : null}
|
|
339
336
|
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
|
340
337
|
/>
|
|
341
338
|
{/* Beat dots on the active track (the one holding the selection),
|
|
@@ -56,14 +56,14 @@ export function TimelineLayerGroupHeader({
|
|
|
56
56
|
}}
|
|
57
57
|
>
|
|
58
58
|
<div
|
|
59
|
-
className="sticky left-0 z-
|
|
59
|
+
className="sticky left-0 z-13 flex h-full items-center"
|
|
60
60
|
style={{
|
|
61
61
|
width: Math.min(GUTTER + 220, GUTTER + trackContentWidth),
|
|
62
62
|
background: theme.gutterBackground,
|
|
63
63
|
}}
|
|
64
64
|
>
|
|
65
65
|
<div
|
|
66
|
-
className="relative h-full
|
|
66
|
+
className="relative h-full shrink-0"
|
|
67
67
|
style={{
|
|
68
68
|
width: GUTTER,
|
|
69
69
|
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
@@ -52,6 +52,10 @@ interface TimelineOverlaysProps {
|
|
|
52
52
|
onSplitElement: TimelineEditCallbacks["onSplitElement"];
|
|
53
53
|
pinZoomBeforeEdit: () => void;
|
|
54
54
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
55
|
+
onCopyClip?: () => boolean;
|
|
56
|
+
onPasteClip?: () => Promise<void>;
|
|
57
|
+
onDuplicateClip?: () => Promise<boolean>;
|
|
58
|
+
canPasteClip?: () => boolean;
|
|
55
59
|
gapContextMenu: TrackGapContextMenuState | null;
|
|
56
60
|
onDismissGapContextMenu: () => void;
|
|
57
61
|
onCloseTrackGap: () => void;
|
|
@@ -119,6 +123,10 @@ export function TimelineOverlays({
|
|
|
119
123
|
onSplitElement,
|
|
120
124
|
pinZoomBeforeEdit,
|
|
121
125
|
onDeleteElement,
|
|
126
|
+
onCopyClip,
|
|
127
|
+
onPasteClip,
|
|
128
|
+
onDuplicateClip,
|
|
129
|
+
canPasteClip,
|
|
122
130
|
gapContextMenu,
|
|
123
131
|
onDismissGapContextMenu,
|
|
124
132
|
onCloseTrackGap,
|
|
@@ -249,6 +257,10 @@ export function TimelineOverlays({
|
|
|
249
257
|
pinZoomBeforeEdit();
|
|
250
258
|
onDeleteElement?.(element);
|
|
251
259
|
}}
|
|
260
|
+
onCopy={onCopyClip}
|
|
261
|
+
onPaste={onPasteClip}
|
|
262
|
+
onDuplicate={onDuplicateClip}
|
|
263
|
+
canPaste={canPasteClip?.() ?? false}
|
|
252
264
|
/>
|
|
253
265
|
)}
|
|
254
266
|
|
|
@@ -85,7 +85,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
85
85
|
style={{ height: RULER_H, width: contentOrigin + trackContentWidth, zIndex: 70 }}
|
|
86
86
|
>
|
|
87
87
|
<div
|
|
88
|
-
className="sticky left-0 z-
|
|
88
|
+
className="sticky left-0 z-12 shrink-0"
|
|
89
89
|
style={{
|
|
90
90
|
width: contentOrigin,
|
|
91
91
|
// Ruler corner uses the panel surface — same as the ruler strip itself.
|
|
@@ -12,7 +12,7 @@ export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) {
|
|
|
12
12
|
style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
|
|
13
13
|
>
|
|
14
14
|
<kbd
|
|
15
|
-
className="text-[9px] font-mono px-1 py-0.5 rounded"
|
|
15
|
+
className="text-[9px] font-mono px-1 py-0.5 rounded-sm"
|
|
16
16
|
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
|
|
17
17
|
>
|
|
18
18
|
Shift
|
|
@@ -259,7 +259,7 @@ export function TimelineTrackHeader({
|
|
|
259
259
|
<div
|
|
260
260
|
role="rowheader"
|
|
261
261
|
aria-colindex={1}
|
|
262
|
-
className="sticky left-0 z-
|
|
262
|
+
className="sticky left-0 z-12 shrink-0"
|
|
263
263
|
style={{
|
|
264
264
|
width: showTrackLabel ? LABEL_COL_W : contentOrigin,
|
|
265
265
|
background: gutterFill(theme.gutterBackground, isGroupMember),
|
|
@@ -39,7 +39,7 @@ export function VisibilityButton({
|
|
|
39
39
|
type="button"
|
|
40
40
|
aria-label={label}
|
|
41
41
|
title={label}
|
|
42
|
-
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible
|
|
42
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-[#3CE6AC] ${
|
|
43
43
|
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
|
|
44
44
|
}`}
|
|
45
45
|
onPointerDown={(event) => event.stopPropagation()}
|
|
@@ -97,10 +97,10 @@ export function PlainTrackHeader({
|
|
|
97
97
|
|
|
98
98
|
Wraps rather than truncating: a truncated name needs a hover to be
|
|
99
99
|
read at all, and a tooltip is no use to a name you are scanning a
|
|
100
|
-
column of. `break-
|
|
100
|
+
column of. `wrap-break-word` so a long single token breaks instead of
|
|
101
101
|
forcing the column wider. */}
|
|
102
102
|
{showTrackLabel && (
|
|
103
|
-
<span className="min-w-0 break-
|
|
103
|
+
<span className="min-w-0 wrap-break-word text-[11px] leading-tight">{trackLabel}</span>
|
|
104
104
|
)}
|
|
105
105
|
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
|
106
106
|
{/* `ml-auto` is what anchors the group right: it absorbs the slack the
|
|
@@ -12,7 +12,8 @@ export interface TimelineClipRenderContext {
|
|
|
12
12
|
export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
|
|
13
13
|
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
|
14
14
|
sessionEpoch?: number;
|
|
15
|
-
|
|
15
|
+
/** keepPlaying: true preserves the current play state across the seek. */
|
|
16
|
+
onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
16
17
|
onDrillDown?: (element: TimelineElement) => void;
|
|
17
18
|
renderClipContent?: (
|
|
18
19
|
element: TimelineElement,
|
|
@@ -22,5 +23,9 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri
|
|
|
22
23
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
23
24
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
24
25
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
26
|
+
onCopyClip?: () => boolean;
|
|
27
|
+
onPasteClip?: () => Promise<void>;
|
|
28
|
+
onDuplicateClip?: () => Promise<boolean>;
|
|
29
|
+
canPasteClip?: () => boolean;
|
|
25
30
|
theme?: Partial<TimelineTheme>;
|
|
26
31
|
}
|
|
@@ -75,7 +75,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
|
|
|
75
75
|
return createPortal(
|
|
76
76
|
<div
|
|
77
77
|
ref={menuRef}
|
|
78
|
-
className="fixed z-
|
|
78
|
+
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
79
79
|
style={{ left: adjustedX, top: adjustedY }}
|
|
80
80
|
onPointerLeave={() => onHoverAction(null)}
|
|
81
81
|
>
|
|
@@ -94,7 +94,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
94
94
|
return (
|
|
95
95
|
<div
|
|
96
96
|
key={index}
|
|
97
|
-
className="relative h-full
|
|
97
|
+
className="relative h-full shrink-0 overflow-hidden bg-neutral-900"
|
|
98
98
|
style={{ width: frameW }}
|
|
99
99
|
>
|
|
100
100
|
<img
|
|
@@ -119,7 +119,9 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
119
119
|
)}
|
|
120
120
|
{snapshot.status === "error" && (
|
|
121
121
|
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900/60">
|
|
122
|
-
<span className="rounded bg-black/50 px-1 text-[8px] text-neutral-500">
|
|
122
|
+
<span className="rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
|
|
123
|
+
no preview
|
|
124
|
+
</span>
|
|
123
125
|
</div>
|
|
124
126
|
)}
|
|
125
127
|
{label && (
|
|
@@ -52,7 +52,7 @@ export const VolumeControl = memo(function VolumeControl({
|
|
|
52
52
|
const muteLabel = silent ? "Unmute audio" : "Mute audio";
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
|
-
<div className="group flex
|
|
55
|
+
<div className="group flex shrink-0 items-center">
|
|
56
56
|
<div className="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-150 ease-out group-hover:w-14 group-hover:opacity-100 group-focus-within:w-14 group-focus-within:opacity-100">
|
|
57
57
|
<div className="relative mx-1 flex h-6 w-12 items-center">
|
|
58
58
|
<div className="absolute inset-x-0 h-0.5 overflow-hidden rounded-full bg-neutral-700">
|
|
@@ -92,7 +92,7 @@ export const VolumeControl = memo(function VolumeControl({
|
|
|
92
92
|
disabled={disabled}
|
|
93
93
|
aria-label={muteLabel}
|
|
94
94
|
aria-pressed={silent}
|
|
95
|
-
className={`flex h-7 w-7
|
|
95
|
+
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
|
|
96
96
|
silent ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
|
|
97
97
|
}`}
|
|
98
98
|
>
|
|
@@ -3,7 +3,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
|
3
3
|
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
4
4
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
5
5
|
|
|
6
|
-
/** Whether Studio may write timing to this clip (false for locked
|
|
6
|
+
/** Whether Studio may write timing to this clip (false for locked rows). */
|
|
7
7
|
export function canMoveTimelineElement(element: TimelineElement): boolean {
|
|
8
8
|
return getTimelineEditCapabilities({
|
|
9
9
|
tag: element.tag,
|
|
@@ -16,7 +16,6 @@ export function canMoveTimelineElement(element: TimelineElement): boolean {
|
|
|
16
16
|
playbackStart: element.playbackStart,
|
|
17
17
|
playbackStartAttr: element.playbackStartAttr,
|
|
18
18
|
sourceDuration: element.sourceDuration,
|
|
19
|
-
timingSource: element.timingSource,
|
|
20
19
|
timelineLocked: element.timelineLocked,
|
|
21
20
|
}).canMove;
|
|
22
21
|
}
|
|
@@ -14,20 +14,20 @@ export interface TimelinePropertyGroupKeyframeToggle {
|
|
|
14
14
|
remove: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Where an outside drop lands; `insertRow` opens a new track at that row boundary of `trackOrder`. */
|
|
18
|
+
export type TimelineDropPlacement = { start: number; track: number } & (
|
|
19
|
+
| { insertRow?: null; trackOrder?: undefined }
|
|
20
|
+
| { insertRow: number; trackOrder: readonly number[] }
|
|
21
|
+
);
|
|
22
|
+
|
|
17
23
|
/**
|
|
18
24
|
* Shared callback signatures for timeline editing operations.
|
|
19
25
|
* Used by NLELayout, Timeline, and any component that passes through
|
|
20
26
|
* the standard set of timeline mutation handlers.
|
|
21
27
|
*/
|
|
22
28
|
export interface TimelineDropCallbacks {
|
|
23
|
-
onFileDrop?: (
|
|
24
|
-
|
|
25
|
-
placement?: { start: number; track: number },
|
|
26
|
-
) => Promise<void> | void;
|
|
27
|
-
onAssetDrop?: (
|
|
28
|
-
assetPath: string,
|
|
29
|
-
placement: { start: number; track: number },
|
|
30
|
-
) => Promise<void> | void;
|
|
29
|
+
onFileDrop?: (files: File[], placement?: TimelineDropPlacement) => Promise<void> | void;
|
|
30
|
+
onAssetDrop?: (assetPath: string, placement: TimelineDropPlacement) => Promise<void> | void;
|
|
31
31
|
onBlockDrop?: (
|
|
32
32
|
blockName: string,
|
|
33
33
|
placement: { start: number; track: number },
|
|
@@ -11,10 +11,10 @@ const clip: TimelineElement = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
describe("resolveClipRenderContext", () => {
|
|
14
|
-
it("prioritizes interactive clips
|
|
14
|
+
it("prioritizes interactive clips without changing their thumbnail frames", () => {
|
|
15
15
|
expect(resolveClipRenderContext(clip, { start: 0, end: 1 }, true)).toEqual({
|
|
16
16
|
priority: "interaction",
|
|
17
|
-
rich:
|
|
17
|
+
rich: false,
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
20
|
|
|
@@ -9,7 +9,8 @@ export function resolveClipRenderContext(
|
|
|
9
9
|
visibleTimeRange: TimelineTimeRange,
|
|
10
10
|
interactive: boolean,
|
|
11
11
|
): TimelineClipRenderContext {
|
|
12
|
-
|
|
12
|
+
// Interaction only reorders loading; `rich` would swap the frames under the pointer.
|
|
13
|
+
if (interactive) return { priority: "interaction", rich: false };
|
|
13
14
|
const visible =
|
|
14
15
|
element.start < visibleTimeRange.end &&
|
|
15
16
|
element.start + element.duration > visibleTimeRange.start;
|
|
@@ -176,8 +176,8 @@ export function persistMoveEdits(
|
|
|
176
176
|
/**
|
|
177
177
|
* Build the time-shift resolver for a multi-selection drag: every member of the
|
|
178
178
|
* selection moves by the dragged clip's delta (clamped ≥ 0); non-members are
|
|
179
|
-
* untouched. Returns null when this is not a multi-selection drag. A locked
|
|
180
|
-
*
|
|
179
|
+
* untouched. Returns null when this is not a multi-selection drag. A locked
|
|
180
|
+
* member is dropped from the moving set (a marquee can sweep one in).
|
|
181
181
|
*/
|
|
182
182
|
function resolveMultiSelection(
|
|
183
183
|
drag: DraggedClipState,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
snapMoveToTargets,
|
|
13
13
|
snapTimelineTime,
|
|
14
14
|
type TimelineSnapTarget,
|
|
15
|
+
type TimelineSnapType,
|
|
15
16
|
} from "./timelineSnapping";
|
|
16
17
|
import { resolveInsertRow, resolveZoneDropPlacement } from "./timelineCollision";
|
|
17
18
|
import {
|
|
@@ -21,11 +22,13 @@ import {
|
|
|
21
22
|
import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
|
|
22
23
|
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
|
|
23
24
|
import { resolveDragLandingStart } from "./timelineDragLanding";
|
|
25
|
+
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
24
26
|
|
|
25
27
|
/** Snap-target builder closure supplied by the hook (closes over refs + store). */
|
|
26
28
|
type BuildSnapTargets = (
|
|
27
29
|
excludeElementKey: string | null,
|
|
28
30
|
includeBeats: boolean,
|
|
31
|
+
includePlayhead?: boolean,
|
|
29
32
|
) => TimelineSnapTarget[];
|
|
30
33
|
|
|
31
34
|
export interface DragPreviewContext {
|
|
@@ -223,6 +226,14 @@ export function computeDragPreview(
|
|
|
223
226
|
};
|
|
224
227
|
}
|
|
225
228
|
|
|
229
|
+
/** One frame: the last visible frame of a clip sits just before its end time. */
|
|
230
|
+
const TRIM_END_FRAME_LEAD_S = 1 / STUDIO_PREVIEW_FPS;
|
|
231
|
+
|
|
232
|
+
/** The composition time whose frame a trim shows: the edge being dragged. */
|
|
233
|
+
export function trimPreviewTime(edge: "start" | "end", start: number, duration: number): number {
|
|
234
|
+
return edge === "start" ? start : Math.max(start, start + duration - TRIM_END_FRAME_LEAD_S);
|
|
235
|
+
}
|
|
236
|
+
|
|
226
237
|
export interface ResizePreviewContext {
|
|
227
238
|
scroll: HTMLDivElement | null;
|
|
228
239
|
pps: number;
|
|
@@ -234,6 +245,9 @@ export interface ResizePreviewResult {
|
|
|
234
245
|
previewStart: number;
|
|
235
246
|
previewDuration: number;
|
|
236
247
|
previewPlaybackStart?: number;
|
|
248
|
+
/** The target the trimmed edge snapped to; null when the edge is free. */
|
|
249
|
+
snapTime: number | null;
|
|
250
|
+
snapType: TimelineSnapType | null;
|
|
237
251
|
}
|
|
238
252
|
|
|
239
253
|
/** Compute the trim preview for a pointer x (pure — the hook applies the state). */
|
|
@@ -287,28 +301,31 @@ export function computeResizePreview(
|
|
|
287
301
|
effectiveClientX,
|
|
288
302
|
);
|
|
289
303
|
|
|
290
|
-
// Snap
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
// still snaps to the playhead and other clip edges.
|
|
304
|
+
// Snap to beats and clip edges, never the playhead (the dragged edge drives
|
|
305
|
+
// its own preview seek, so that would be circular). Stay inside the same
|
|
306
|
+
// limits resolveTimelineResize enforces. The music track defines the
|
|
307
|
+
// beats, so it must not snap to them, but still snaps to clip edges.
|
|
295
308
|
const trimTargets = buildSnapTargets(
|
|
296
309
|
resize.element.key ?? resize.element.id,
|
|
297
310
|
!isMusicTrack(resize.element),
|
|
311
|
+
false,
|
|
298
312
|
);
|
|
313
|
+
let snap: TimelineSnapTarget | null = null;
|
|
299
314
|
if (trimTargets.length > 0) {
|
|
300
315
|
const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
|
|
301
316
|
if (resize.edge === "end") {
|
|
302
317
|
const edgeTime = nextResize.start + nextResize.duration;
|
|
303
|
-
const snapped = snapTimelineTime(edgeTime, trimTargets, snapSecs)
|
|
318
|
+
const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs);
|
|
304
319
|
// Stay within [start+minDuration, maxEnd] so the snap can't create a
|
|
305
320
|
// degenerate clip or run past the source/composition limit.
|
|
306
321
|
const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
|
|
307
|
-
if (
|
|
308
|
-
|
|
322
|
+
if (target && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
|
|
323
|
+
// An edge already on the target still owns the guide; only move it when off.
|
|
324
|
+
if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration };
|
|
325
|
+
snap = target;
|
|
309
326
|
}
|
|
310
327
|
} else {
|
|
311
|
-
const snapped = snapTimelineTime(nextResize.start, trimTargets, snapSecs)
|
|
328
|
+
const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
|
|
312
329
|
const delta = nextResize.start - snapped; // >0 when snapping left
|
|
313
330
|
// Leftward snap reveals more source; cap so playbackStart can't go < 0.
|
|
314
331
|
const maxLeftDelta =
|
|
@@ -318,22 +335,20 @@ export function computeResizePreview(
|
|
|
318
335
|
// Also require the resulting duration to stay >= minDuration so a rightward
|
|
319
336
|
// snap (delta < 0) can't collapse the clip to zero/negative.
|
|
320
337
|
const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
|
|
321
|
-
if (
|
|
322
|
-
snapped !== nextResize.start
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
: undefined,
|
|
336
|
-
};
|
|
338
|
+
if (target && snapped >= 0 && delta <= maxLeftDelta + 1e-6 && snappedDuration >= 0.05) {
|
|
339
|
+
if (snapped !== nextResize.start) {
|
|
340
|
+
nextResize = {
|
|
341
|
+
...nextResize,
|
|
342
|
+
start: snapped,
|
|
343
|
+
duration: snappedDuration,
|
|
344
|
+
playbackStart:
|
|
345
|
+
nextResize.playbackStart != null
|
|
346
|
+
? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
|
|
347
|
+
1000
|
|
348
|
+
: undefined,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
snap = target;
|
|
337
352
|
}
|
|
338
353
|
}
|
|
339
354
|
}
|
|
@@ -343,6 +358,8 @@ export function computeResizePreview(
|
|
|
343
358
|
previewStart: nextResize.start,
|
|
344
359
|
previewDuration: nextResize.duration,
|
|
345
360
|
previewPlaybackStart: nextResize.playbackStart,
|
|
361
|
+
snapTime: snap?.time ?? null,
|
|
362
|
+
snapType: snap?.type ?? null,
|
|
346
363
|
};
|
|
347
364
|
}
|
|
348
365
|
|
|
@@ -359,11 +376,18 @@ export function previewGroupResize(
|
|
|
359
376
|
) => void,
|
|
360
377
|
): void {
|
|
361
378
|
const grabbedChange = applyTimelineGroupResizePreview(session, next);
|
|
379
|
+
const previewStart = grabbedChange?.start ?? next.previewStart;
|
|
380
|
+
const previewDuration = grabbedChange?.duration ?? next.previewDuration;
|
|
381
|
+
// A member clamp can pull the grabbed edge off the raw snap target; then no guide.
|
|
382
|
+
const edgeTime = session.edge === "end" ? previewStart + previewDuration : previewStart;
|
|
383
|
+
const stillSnapped = next.snapTime != null && Math.abs(edgeTime - next.snapTime) < 1e-3;
|
|
362
384
|
setResizeState({
|
|
363
385
|
originScrollLeft: next.originScrollLeft,
|
|
364
|
-
previewStart
|
|
365
|
-
previewDuration
|
|
386
|
+
previewStart,
|
|
387
|
+
previewDuration,
|
|
366
388
|
previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
|
|
389
|
+
snapTime: stillSnapped ? next.snapTime : null,
|
|
390
|
+
snapType: stillSnapped ? next.snapType : null,
|
|
367
391
|
groupPreview: session.changes,
|
|
368
392
|
});
|
|
369
393
|
}
|
|
@@ -57,6 +57,9 @@ export interface ResizingClipState {
|
|
|
57
57
|
previewStart: number;
|
|
58
58
|
previewDuration: number;
|
|
59
59
|
previewPlaybackStart?: number;
|
|
60
|
+
/** Snap target the trimmed edge landed on, for the guide highlight. */
|
|
61
|
+
snapTime?: number | null;
|
|
62
|
+
snapType?: TimelineSnapType | null;
|
|
60
63
|
/** Coordinator-owned group projection; canonical elements change only on commit. */
|
|
61
64
|
groupPreview?: readonly {
|
|
62
65
|
key: string;
|
|
@@ -6,7 +6,8 @@ import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
|
6
6
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
7
7
|
import { usePlayerStore } from "../store/playerStore";
|
|
8
8
|
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
9
|
-
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
|
9
|
+
import { resolveDropInsertRow, useTimelineAssetDrop } from "./timelineDragDrop";
|
|
10
|
+
import { getTimelineRowTop, TRACK_H } from "./timelineLayout";
|
|
10
11
|
import { configureTimelineTestViewport } from "./timelineTestViewport";
|
|
11
12
|
|
|
12
13
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -220,4 +221,94 @@ describe("useTimelineAssetDrop", () => {
|
|
|
220
221
|
expect(view.api.isDragOver).toBe(false);
|
|
221
222
|
act(() => view.root.unmount());
|
|
222
223
|
});
|
|
224
|
+
|
|
225
|
+
it("previews the exact placement the drop commits, and clears it after", () => {
|
|
226
|
+
const onAssetDrop = vi.fn();
|
|
227
|
+
const view = renderHarness(onAssetDrop);
|
|
228
|
+
const payload = JSON.stringify({ path: "assets/a.png" });
|
|
229
|
+
|
|
230
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
|
|
231
|
+
const preview = view.api.dropPreview;
|
|
232
|
+
expect(preview).toEqual({ start: 10, track: 0 });
|
|
233
|
+
|
|
234
|
+
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
|
|
235
|
+
expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
|
|
236
|
+
expect(view.api.dropPreview).toBeNull();
|
|
237
|
+
act(() => view.root.unmount());
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("moves the preview with the pointer and drops it when the drag leaves", () => {
|
|
241
|
+
const view = renderHarness(vi.fn());
|
|
242
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
|
|
243
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
|
|
244
|
+
expect(view.api.dropPreview?.start).toBe(20);
|
|
245
|
+
|
|
246
|
+
act(() =>
|
|
247
|
+
view.api.handleAssetDragLeave({
|
|
248
|
+
relatedTarget: null,
|
|
249
|
+
currentTarget: document.body,
|
|
250
|
+
} as unknown as React.DragEvent),
|
|
251
|
+
);
|
|
252
|
+
expect(view.api.dropPreview).toBeNull();
|
|
253
|
+
act(() => view.root.unmount());
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("resolveDropInsertRow", () => {
|
|
258
|
+
const rows = [TRACK_H, TRACK_H, TRACK_H];
|
|
259
|
+
it("arms a new track at the boundary between two lanes", () => {
|
|
260
|
+
expect(resolveDropInsertRow(getTimelineRowTop(1, rows), rows, 3)).toBe(1);
|
|
261
|
+
});
|
|
262
|
+
it("arms a new track above the first lane", () => {
|
|
263
|
+
expect(resolveDropInsertRow(getTimelineRowTop(0, rows) - 4, rows, 3)).toBe(0);
|
|
264
|
+
});
|
|
265
|
+
it("stays on the lane when the pointer is over its middle", () => {
|
|
266
|
+
expect(resolveDropInsertRow(getTimelineRowTop(1, rows) + TRACK_H / 2, rows, 3)).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
it("leaves the area below the last lane to the append path", () => {
|
|
269
|
+
expect(resolveDropInsertRow(getTimelineRowTop(2, rows) + TRACK_H + 4, rows, 3)).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
it("never arms on an empty timeline", () => {
|
|
272
|
+
expect(resolveDropInsertRow(80, [], 0)).toBeNull();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("useTimelineAssetDrop new-track drops", () => {
|
|
277
|
+
it("previews and commits an insert row for an asset dropped between lanes", () => {
|
|
278
|
+
const onAssetDrop = vi.fn();
|
|
279
|
+
const view = renderHarness(onAssetDrop);
|
|
280
|
+
const transfer = assetTransfer(JSON.stringify({ path: "assets/a.png" }));
|
|
281
|
+
const y = getTimelineRowTop(1) + 1;
|
|
282
|
+
|
|
283
|
+
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, y)));
|
|
284
|
+
expect(view.api.dropPreview).toMatchObject({ start: 10, insertRow: 1 });
|
|
285
|
+
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, y)));
|
|
286
|
+
expect(onAssetDrop).toHaveBeenCalledWith(
|
|
287
|
+
"assets/a.png",
|
|
288
|
+
expect.objectContaining({
|
|
289
|
+
start: 10,
|
|
290
|
+
insertRow: 1,
|
|
291
|
+
trackOrder: Array.from({ length: 100 }, (_, index) => index),
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
act(() => view.root.unmount());
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("does not ask a block drop to insert a track", () => {
|
|
298
|
+
const onBlockDrop = vi.fn();
|
|
299
|
+
const view = renderHarness(vi.fn(), 1, { onBlockDrop });
|
|
300
|
+
const transfer: DropTransfer = {
|
|
301
|
+
types: [TIMELINE_BLOCK_MIME],
|
|
302
|
+
files: [],
|
|
303
|
+
dropEffect: "none",
|
|
304
|
+
getData: (type) => (type === TIMELINE_BLOCK_MIME ? JSON.stringify({ name: "b" }) : ""),
|
|
305
|
+
};
|
|
306
|
+
const y = getTimelineRowTop(1) + 1;
|
|
307
|
+
act(() => {
|
|
308
|
+
view.api.handleAssetDragOver(dragEvent(transfer, 400, y));
|
|
309
|
+
view.api.handleAssetDrop(dragEvent(transfer, 400, y));
|
|
310
|
+
});
|
|
311
|
+
expect(onBlockDrop).toHaveBeenCalledWith("b", { start: 10, track: 1 });
|
|
312
|
+
act(() => view.root.unmount());
|
|
313
|
+
});
|
|
223
314
|
});
|