@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
|
@@ -7,25 +7,53 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
|
7
7
|
import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Why an optional project-file read produced no usable content. `stage: "read"`
|
|
11
|
+
* was a single opaque reason covering all of these, which made the largest
|
|
12
|
+
* remaining class of SDK-session failures undiagnosable: 56 users in a 7-day
|
|
13
|
+
* window hit it and, between them, never landed a single successful SDK edit.
|
|
14
|
+
* Knowing which branch fired is the difference between "the file legitimately
|
|
15
|
+
* is not there" and "the request never reached the file".
|
|
16
|
+
*
|
|
17
|
+
* Every reason lives in this union so the full surface is readable from one
|
|
18
|
+
* place — `absent_or_empty` included, even though it is a 2xx.
|
|
19
|
+
*/
|
|
20
|
+
type ProjectFileReadFailure =
|
|
21
|
+
| { ok: false; reason: "unsafe_path" }
|
|
22
|
+
| { ok: false; reason: "http_error"; status: number }
|
|
23
|
+
| { ok: false; reason: "missing_content" }
|
|
24
|
+
| { ok: false; reason: "absent_or_empty" };
|
|
25
|
+
|
|
26
|
+
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read a project file's content (optional read — a missing file is not an
|
|
30
|
+
* error). Replaces the removed SDK http adapter's `read()` — the only thing
|
|
31
|
+
* Studio used it for (Studio is the sole writer, so the adapter's write path
|
|
32
|
+
* was dead).
|
|
13
33
|
*/
|
|
14
34
|
async function readProjectFileOptional(
|
|
15
35
|
projectId: string,
|
|
16
36
|
path: string,
|
|
17
|
-
): Promise<
|
|
37
|
+
): Promise<ProjectFileReadResult> {
|
|
18
38
|
// Reject traversal / NUL before building the request URL — `path` is a
|
|
19
39
|
// user-influenced composition path (mirrors the guard in timelineEditingHelpers,
|
|
20
40
|
// and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
|
|
21
41
|
// already confines both values to single segments of this same-origin URL.
|
|
22
|
-
if (path.includes("\0") || path.includes("..")) return
|
|
42
|
+
if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
|
|
23
43
|
const res = await fetch(
|
|
24
44
|
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
25
45
|
);
|
|
26
|
-
if (!res.ok) return
|
|
46
|
+
if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
|
|
27
47
|
const data = (await res.json()) as { content?: string };
|
|
28
|
-
|
|
48
|
+
// `optional=1` answers a missing file with 200 + `content: ""`, so a
|
|
49
|
+
// non-string here means a response shape we did not expect, not absence.
|
|
50
|
+
if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
|
|
51
|
+
// An empty body parses into a session with no elements, which declines every
|
|
52
|
+
// edit wholesale — not a session worth opening. The absent-file shim and a
|
|
53
|
+
// genuinely 0-byte file are the same 200 on the wire and cannot be told
|
|
54
|
+
// apart here, hence the name; for a composition it is always the former.
|
|
55
|
+
if (data.content === "") return { ok: false, reason: "absent_or_empty" };
|
|
56
|
+
return { ok: true, content: data.content };
|
|
29
57
|
}
|
|
30
58
|
|
|
31
59
|
/**
|
|
@@ -180,16 +208,21 @@ export function useSdkSession(
|
|
|
180
208
|
};
|
|
181
209
|
|
|
182
210
|
readProjectFileOptional(projectId, activeCompPath)
|
|
183
|
-
.then(async (
|
|
211
|
+
.then(async (read) => {
|
|
184
212
|
if (cancelled) return;
|
|
185
|
-
if (
|
|
213
|
+
if (!read.ok) {
|
|
186
214
|
// No SDK session follows, so EVERY cutover chokepoint below takes the
|
|
187
215
|
// server path and emits nothing — the shadow never runs either. This
|
|
188
216
|
// is the only place a missing session can originate, so a broken read
|
|
189
217
|
// would otherwise be a silent, total SDK bypass.
|
|
190
|
-
trackStudioEvent("sdk_session_unavailable", {
|
|
218
|
+
trackStudioEvent("sdk_session_unavailable", {
|
|
219
|
+
stage: "read",
|
|
220
|
+
reason: read.reason,
|
|
221
|
+
...(read.reason === "http_error" ? { status: read.status } : {}),
|
|
222
|
+
});
|
|
191
223
|
return;
|
|
192
224
|
}
|
|
225
|
+
const content = read.content;
|
|
193
226
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
194
227
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
195
228
|
// queue too would double-write the file (queue auto-writes on every
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useMemo,
|
|
1
|
+
import { useCallback, useMemo, type DragEvent } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
3
|
import type { StudioContextValue } from "../contexts/StudioContext";
|
|
4
4
|
import type { RightInspectorPanes } from "../utils/studioHelpers";
|
|
@@ -116,35 +116,21 @@ export function useInspectorState(
|
|
|
116
116
|
]);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
function
|
|
121
|
-
const [active, setActive] = useState(false);
|
|
122
|
-
const counterRef = useRef(0);
|
|
119
|
+
/** Lets an OS file drop reach `onDrop` anywhere in the shell; the timeline shows its own landing preview. */
|
|
120
|
+
function useFileDropTarget(onImportFiles: (files: FileList) => void) {
|
|
123
121
|
const onDragOver = useCallback((e: DragEvent) => {
|
|
124
122
|
if (!e.dataTransfer.types.includes("Files")) return;
|
|
125
123
|
e.preventDefault();
|
|
126
124
|
}, []);
|
|
127
|
-
const onDragEnter = useCallback((e: DragEvent) => {
|
|
128
|
-
if (!e.dataTransfer.types.includes("Files")) return;
|
|
129
|
-
e.preventDefault();
|
|
130
|
-
counterRef.current++;
|
|
131
|
-
setActive(true);
|
|
132
|
-
}, []);
|
|
133
|
-
const onDragLeave = useCallback(() => {
|
|
134
|
-
counterRef.current--;
|
|
135
|
-
if (counterRef.current === 0) setActive(false);
|
|
136
|
-
}, []);
|
|
137
125
|
const onDrop = useCallback(
|
|
138
126
|
(e: DragEvent) => {
|
|
139
|
-
counterRef.current = 0;
|
|
140
|
-
setActive(false);
|
|
141
127
|
if (e.defaultPrevented) return;
|
|
142
128
|
e.preventDefault();
|
|
143
129
|
if (e.dataTransfer.files.length) onImportFiles(e.dataTransfer.files);
|
|
144
130
|
},
|
|
145
131
|
[onImportFiles],
|
|
146
132
|
);
|
|
147
|
-
return {
|
|
133
|
+
return { onDragOver, onDrop };
|
|
148
134
|
}
|
|
149
135
|
|
|
150
136
|
/** Global OS file drop: imports and places at the playhead position. */
|
|
@@ -156,5 +142,5 @@ export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandle
|
|
|
156
142
|
},
|
|
157
143
|
[handleTimelineFileDrop],
|
|
158
144
|
);
|
|
159
|
-
return
|
|
145
|
+
return useFileDropTarget(onDrop);
|
|
160
146
|
}
|
|
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
6
6
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
|
+
import type { TimelineElement } from "../player";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
@@ -19,6 +20,7 @@ type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDro
|
|
|
19
20
|
function renderDropHook(
|
|
20
21
|
sourceContent: string,
|
|
21
22
|
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
23
|
+
timelineElements: TimelineElement[] = [],
|
|
22
24
|
) {
|
|
23
25
|
vi.stubGlobal(
|
|
24
26
|
"fetch",
|
|
@@ -29,7 +31,7 @@ function renderDropHook(
|
|
|
29
31
|
const { handleTimelineAssetDrop } = useTimelineAssetDropOps({
|
|
30
32
|
projectIdRef: { current: "project" },
|
|
31
33
|
activeCompPath: "index.html",
|
|
32
|
-
timelineElements
|
|
34
|
+
timelineElements,
|
|
33
35
|
showToast: vi.fn(),
|
|
34
36
|
writeProjectFile,
|
|
35
37
|
recordEdit: vi.fn().mockResolvedValue(undefined),
|
|
@@ -84,4 +86,42 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
84
86
|
|
|
85
87
|
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
|
|
86
88
|
});
|
|
89
|
+
|
|
90
|
+
it("measures the insert row against the visible rows, not clips the timeline hides", async () => {
|
|
91
|
+
const clip = (id: string, track: number): TimelineElement => ({
|
|
92
|
+
id,
|
|
93
|
+
key: id,
|
|
94
|
+
tag: "div",
|
|
95
|
+
start: 0,
|
|
96
|
+
duration: 2,
|
|
97
|
+
track,
|
|
98
|
+
authoredTrack: track,
|
|
99
|
+
hfId: `hf-${id}`,
|
|
100
|
+
domId: id,
|
|
101
|
+
});
|
|
102
|
+
const source = [
|
|
103
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080">',
|
|
104
|
+
...["a:0", "hidden:1", "b:2"].map((t) => {
|
|
105
|
+
const [id, track] = t.split(":");
|
|
106
|
+
return `<div data-hf-id="hf-${id}" id="${id}" data-start="0" data-track-index="${track}"></div>`;
|
|
107
|
+
}),
|
|
108
|
+
"</main>",
|
|
109
|
+
].join("\n");
|
|
110
|
+
usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
|
|
111
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
112
|
+
const getDrop = renderDropHook(source, writeProjectFile, [
|
|
113
|
+
clip("a", 0),
|
|
114
|
+
clip("hidden", 1),
|
|
115
|
+
clip("b", 2),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
await act(async () => {
|
|
119
|
+
await getDrop()("clip.mp4", { start: 1, track: 1, insertRow: 1, trackOrder: [0, 2] }, 2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
123
|
+
expect(written).toContain('id="b" data-start="0" data-track-index="2"');
|
|
124
|
+
expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
|
|
125
|
+
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
|
|
126
|
+
});
|
|
87
127
|
});
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// 600-line cap.
|
|
5
5
|
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
|
6
6
|
import type { TimelineElement } from "../player";
|
|
7
|
+
import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
|
|
8
|
+
import { resolveDropTrack } from "../utils/timelineDropTrackInsert";
|
|
7
9
|
import {
|
|
8
10
|
buildTimelineAssetId,
|
|
9
11
|
buildTimelineAssetInsertHtml,
|
|
@@ -23,6 +25,18 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
|
|
|
23
25
|
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
24
26
|
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
25
27
|
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
28
|
+
import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
29
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
30
|
+
|
|
31
|
+
/** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
|
|
32
|
+
function fileDropPlacement(
|
|
33
|
+
index: number,
|
|
34
|
+
next: { start: number; track: number },
|
|
35
|
+
dropped: TimelineDropPlacement | undefined,
|
|
36
|
+
landedTrack: number | undefined,
|
|
37
|
+
): TimelineDropPlacement {
|
|
38
|
+
return index === 0 ? { ...dropped, ...next } : { ...next, track: landedTrack ?? next.track };
|
|
39
|
+
}
|
|
26
40
|
|
|
27
41
|
interface UseTimelineAssetDropOpsOptions {
|
|
28
42
|
projectIdRef: MutableRefObject<string | null>;
|
|
@@ -52,16 +66,16 @@ export function useTimelineAssetDropOps({
|
|
|
52
66
|
observeProjectFileVersion,
|
|
53
67
|
}: UseTimelineAssetDropOpsOptions) {
|
|
54
68
|
// fallow-ignore-next-line complexity
|
|
55
|
-
const
|
|
69
|
+
const dropAssetAt = useCallback(
|
|
56
70
|
// fallow-ignore-next-line complexity
|
|
57
71
|
async (
|
|
58
72
|
assetPath: string,
|
|
59
|
-
placement:
|
|
73
|
+
placement: TimelineDropPlacement,
|
|
60
74
|
durationOverride?: number,
|
|
61
|
-
) => {
|
|
75
|
+
): Promise<number | undefined> => {
|
|
62
76
|
if (isRecordingRef?.current) {
|
|
63
77
|
showToast("Cannot edit timeline while recording", "error");
|
|
64
|
-
return;
|
|
78
|
+
return undefined;
|
|
65
79
|
}
|
|
66
80
|
const pid = projectIdRef.current;
|
|
67
81
|
if (!pid) throw new Error("No active project");
|
|
@@ -69,7 +83,7 @@ export function useTimelineAssetDropOps({
|
|
|
69
83
|
const kind = getTimelineAssetKind(assetPath);
|
|
70
84
|
if (!kind) {
|
|
71
85
|
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
|
72
|
-
return;
|
|
86
|
+
return undefined;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
const targetPath = activeCompPath || "index.html";
|
|
@@ -91,9 +105,24 @@ export function useTimelineAssetDropOps({
|
|
|
91
105
|
);
|
|
92
106
|
const newElementZIndex = Math.max(1, relevantElements.length + 1);
|
|
93
107
|
|
|
108
|
+
const { source: sourceWithRoom, track } = resolveDropTrack({
|
|
109
|
+
source: originalContent,
|
|
110
|
+
// insertRow counts the rows the timeline shows, so plan against those.
|
|
111
|
+
elements: selectTimelineRowElements(
|
|
112
|
+
relevantElements,
|
|
113
|
+
usePlayerStore.getState().topLevelIds,
|
|
114
|
+
),
|
|
115
|
+
placement,
|
|
116
|
+
dropped: {
|
|
117
|
+
id: newId,
|
|
118
|
+
tag: kind === "image" ? "img" : kind,
|
|
119
|
+
start: normalizedStart,
|
|
120
|
+
duration: normalizedDuration,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
94
123
|
const patchedContent = extendRootDurationInSource(
|
|
95
124
|
insertTimelineAssetIntoSource(
|
|
96
|
-
|
|
125
|
+
sourceWithRoom,
|
|
97
126
|
buildTimelineAssetInsertHtml({
|
|
98
127
|
id: newId,
|
|
99
128
|
hfId: `hf-${generateId()}`,
|
|
@@ -101,7 +130,7 @@ export function useTimelineAssetDropOps({
|
|
|
101
130
|
kind,
|
|
102
131
|
start: normalizedStart,
|
|
103
132
|
duration: normalizedDuration,
|
|
104
|
-
track
|
|
133
|
+
track,
|
|
105
134
|
zIndex: newElementZIndex,
|
|
106
135
|
geometry: fitTimelineAssetGeometry(
|
|
107
136
|
null,
|
|
@@ -125,10 +154,12 @@ export function useTimelineAssetDropOps({
|
|
|
125
154
|
selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
|
|
126
155
|
forceReloadSdkSession?.();
|
|
127
156
|
reloadPreview();
|
|
157
|
+
return track;
|
|
128
158
|
} catch (error) {
|
|
129
159
|
const message =
|
|
130
160
|
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
|
131
161
|
showToast(message);
|
|
162
|
+
return undefined;
|
|
132
163
|
}
|
|
133
164
|
},
|
|
134
165
|
[
|
|
@@ -144,10 +175,17 @@ export function useTimelineAssetDropOps({
|
|
|
144
175
|
],
|
|
145
176
|
);
|
|
146
177
|
|
|
178
|
+
const handleTimelineAssetDrop = useCallback(
|
|
179
|
+
async (assetPath: string, placement: TimelineDropPlacement, durationOverride?: number) => {
|
|
180
|
+
await dropAssetAt(assetPath, placement, durationOverride);
|
|
181
|
+
},
|
|
182
|
+
[dropAssetAt],
|
|
183
|
+
);
|
|
184
|
+
|
|
147
185
|
// fallow-ignore-next-line complexity
|
|
148
186
|
const handleTimelineFileDrop = useCallback(
|
|
149
187
|
// fallow-ignore-next-line complexity
|
|
150
|
-
async (files: File[], placement?:
|
|
188
|
+
async (files: File[], placement?: TimelineDropPlacement) => {
|
|
151
189
|
if (isRecordingRef?.current) {
|
|
152
190
|
showToast("Cannot edit timeline while recording", "error");
|
|
153
191
|
return;
|
|
@@ -166,15 +204,18 @@ export function useTimelineAssetDropOps({
|
|
|
166
204
|
placement ?? { start: 0, track: 0 },
|
|
167
205
|
durations,
|
|
168
206
|
);
|
|
207
|
+
let landedTrack: number | undefined;
|
|
169
208
|
for (const [index, assetPath] of uploaded.entries()) {
|
|
170
|
-
|
|
209
|
+
const next = placements[index] ?? placements[0];
|
|
210
|
+
const track = await dropAssetAt(
|
|
171
211
|
assetPath,
|
|
172
|
-
|
|
212
|
+
fileDropPlacement(index, next, placement, landedTrack),
|
|
173
213
|
durations[index],
|
|
174
214
|
);
|
|
215
|
+
if (index === 0) landedTrack = track;
|
|
175
216
|
}
|
|
176
217
|
},
|
|
177
|
-
[
|
|
218
|
+
[dropAssetAt, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
|
|
178
219
|
);
|
|
179
220
|
|
|
180
221
|
const handleTimelineCompositionDrop = useCallback(
|
package/src/main.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { StrictMode } from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { IconContext } from "@phosphor-icons/react";
|
|
3
4
|
import { StudioApp } from "./App";
|
|
4
5
|
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
|
|
6
|
+
import { readIconTokens } from "./styles/iconTokens";
|
|
5
7
|
import { trackStudioEvent } from "./utils/studioTelemetry";
|
|
6
8
|
import "./styles/studio.css";
|
|
7
9
|
|
|
@@ -93,8 +95,12 @@ window.addEventListener("unhandledrejection", (event) => {
|
|
|
93
95
|
|
|
94
96
|
createRoot(document.getElementById("root")!).render(
|
|
95
97
|
<StrictMode>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
{/* One icon size and weight for the whole app, taken from the theme file.
|
|
99
|
+
Icons that pass their own size or weight still win. */}
|
|
100
|
+
<IconContext.Provider value={readIconTokens()}>
|
|
101
|
+
<StudioErrorBoundary>
|
|
102
|
+
<StudioApp />
|
|
103
|
+
</StudioErrorBoundary>
|
|
104
|
+
</IconContext.Provider>
|
|
99
105
|
</StrictMode>,
|
|
100
106
|
);
|
|
@@ -191,7 +191,7 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
191
191
|
"repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
|
|
192
192
|
}}
|
|
193
193
|
/>
|
|
194
|
-
<span className="relative rounded bg-black/50 px-1 text-[8px] text-neutral-500">
|
|
194
|
+
<span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
|
|
195
195
|
waveform unavailable
|
|
196
196
|
</span>
|
|
197
197
|
</div>
|
|
@@ -41,11 +41,11 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
|
|
|
41
41
|
return createPortal(
|
|
42
42
|
<div
|
|
43
43
|
ref={menuRef}
|
|
44
|
-
// z-
|
|
44
|
+
// z-200 for the same reason the timeline's FX popover uses it: this is
|
|
45
45
|
// portaled to `document.body`, but the ruler's sticky header sits at z-70
|
|
46
46
|
// in the SAME root stacking context, so a z-50 menu opened near the top of
|
|
47
47
|
// the timeline is painted through by the ruler and the playhead.
|
|
48
|
-
className="hf-automation-menu fixed z-
|
|
48
|
+
className="hf-automation-menu fixed z-200 min-w-[140px] rounded-sm border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
|
|
49
49
|
style={{ left: adjustedX, top: adjustedY }}
|
|
50
50
|
>
|
|
51
51
|
{AUTOMATION_SHAPES.map((shape) => (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import type
|
|
3
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
4
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
5
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
6
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
@@ -13,8 +13,14 @@ interface ClipContextMenuProps {
|
|
|
13
13
|
onClose: () => void;
|
|
14
14
|
onSplit: (element: TimelineElement, splitTime: number) => void;
|
|
15
15
|
onDelete: (element: TimelineElement) => void;
|
|
16
|
+
onCopy?: () => boolean;
|
|
17
|
+
onPaste?: () => Promise<void>;
|
|
18
|
+
onDuplicate?: () => Promise<boolean>;
|
|
19
|
+
canPaste?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
// A menu with many independently gated items (Split/Delete/Copy/Paste/Duplicate).
|
|
23
|
+
// fallow-ignore-next-line complexity
|
|
18
24
|
export const ClipContextMenu = memo(function ClipContextMenu({
|
|
19
25
|
x,
|
|
20
26
|
y,
|
|
@@ -23,15 +29,22 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
23
29
|
onClose,
|
|
24
30
|
onSplit,
|
|
25
31
|
onDelete,
|
|
32
|
+
onCopy,
|
|
33
|
+
onPaste,
|
|
34
|
+
onDuplicate,
|
|
35
|
+
canPaste,
|
|
26
36
|
}: ClipContextMenuProps) {
|
|
27
37
|
const menuRef = useContextMenuDismiss(onClose);
|
|
28
38
|
useMenuKeyboardNav(menuRef);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
// The right-clicked clip's own id: a member of the live multi-selection
|
|
40
|
+
// means Copy/Duplicate act on the whole group, matching onContextMenuClip's
|
|
41
|
+
// selection-preserving behaviour for a right-click inside it.
|
|
42
|
+
const selectionSize = usePlayerStore((s) => {
|
|
43
|
+
const id = element.key ?? element.id;
|
|
44
|
+
return s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)
|
|
45
|
+
? s.selectedElementIds.size
|
|
46
|
+
: 1;
|
|
47
|
+
});
|
|
35
48
|
|
|
36
49
|
const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
|
|
37
50
|
const canSplit =
|
|
@@ -43,12 +56,29 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
43
56
|
? `Split at ${currentTime.toFixed(2)}s`
|
|
44
57
|
: "Split (move playhead inside clip)";
|
|
45
58
|
|
|
59
|
+
const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
|
|
60
|
+
const rowCount = (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
|
|
61
|
+
const dividerCount = (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
|
|
62
|
+
const menuWidth = 200;
|
|
63
|
+
const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
|
|
64
|
+
const overflowY = y + menuHeight - window.innerHeight;
|
|
65
|
+
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
66
|
+
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
67
|
+
|
|
68
|
+
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
|
|
69
|
+
const itemClass = (enabled: boolean) =>
|
|
70
|
+
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
|
|
71
|
+
enabled
|
|
72
|
+
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
73
|
+
: " text-neutral-600 cursor-not-allowed"
|
|
74
|
+
}`;
|
|
75
|
+
|
|
46
76
|
return createPortal(
|
|
47
77
|
<div
|
|
48
78
|
ref={menuRef}
|
|
49
79
|
role="menu"
|
|
50
80
|
aria-label="Clip actions"
|
|
51
|
-
className="fixed z-
|
|
81
|
+
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
52
82
|
style={{ left: adjustedX, top: adjustedY }}
|
|
53
83
|
>
|
|
54
84
|
{splitLabel && (
|
|
@@ -56,7 +86,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
56
86
|
<button
|
|
57
87
|
type="button"
|
|
58
88
|
role="menuitem"
|
|
59
|
-
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-
|
|
89
|
+
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden focus-visible:bg-neutral-800 ${
|
|
60
90
|
canSplit
|
|
61
91
|
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
62
92
|
: "text-neutral-600 cursor-not-allowed"
|
|
@@ -76,10 +106,60 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
76
106
|
</>
|
|
77
107
|
)}
|
|
78
108
|
|
|
109
|
+
{(onCopy || onPaste || onDuplicate) && (
|
|
110
|
+
<>
|
|
111
|
+
{onCopy && (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
role="menuitem"
|
|
115
|
+
className={itemClass(true)}
|
|
116
|
+
onClick={() => {
|
|
117
|
+
onCopy();
|
|
118
|
+
onClose();
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
|
|
122
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
|
|
123
|
+
</button>
|
|
124
|
+
)}
|
|
125
|
+
{onPaste && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
role="menuitem"
|
|
129
|
+
className={itemClass(!!canPaste)}
|
|
130
|
+
disabled={!canPaste}
|
|
131
|
+
onClick={() => {
|
|
132
|
+
if (!canPaste) return;
|
|
133
|
+
void onPaste();
|
|
134
|
+
onClose();
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<span>Paste</span>
|
|
138
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
{onDuplicate && (
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
role="menuitem"
|
|
145
|
+
className={itemClass(true)}
|
|
146
|
+
onClick={() => {
|
|
147
|
+
void onDuplicate();
|
|
148
|
+
onClose();
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
|
|
152
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
|
|
153
|
+
</button>
|
|
154
|
+
)}
|
|
155
|
+
<div className="my-1 border-t border-neutral-700/60" />
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
|
|
79
159
|
<button
|
|
80
160
|
type="button"
|
|
81
161
|
role="menuitem"
|
|
82
|
-
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-
|
|
162
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
|
|
83
163
|
onClick={() => {
|
|
84
164
|
onDelete(element);
|
|
85
165
|
onClose();
|
|
@@ -87,9 +87,6 @@ describe("buildCompositionThumbnailUrl", () => {
|
|
|
87
87
|
|
|
88
88
|
expect(buildCompositionThumbnailUrl(base)).not.toContain("output=");
|
|
89
89
|
expect(buildCompositionThumbnailUrl({ ...base, output: "source" })).toContain("output=source");
|
|
90
|
-
expect(buildCompositionThumbnailUrl({ ...base, output: "storyboard" })).toContain(
|
|
91
|
-
"output=storyboard",
|
|
92
|
-
);
|
|
93
90
|
});
|
|
94
91
|
|
|
95
92
|
it("includes the persisted content revision in the cache identity", () => {
|
|
@@ -44,10 +44,9 @@ export function buildCompositionThumbnailUrl({
|
|
|
44
44
|
/**
|
|
45
45
|
* Capture density. Omitted, the route bounds the image to its preview cap —
|
|
46
46
|
* right for the timeline, where thumbnails are small and numerous and their
|
|
47
|
-
* decoded bytes are budgeted. `"
|
|
48
|
-
* high-density review size; `"source"` uses the composition's own dimensions.
|
|
47
|
+
* decoded bytes are budgeted. `"source"` uses the composition's own dimensions.
|
|
49
48
|
*/
|
|
50
|
-
output?: "source"
|
|
49
|
+
output?: "source";
|
|
51
50
|
contentRevision?: number;
|
|
52
51
|
}): string {
|
|
53
52
|
const thumbnailBase = previewUrl
|
|
@@ -159,7 +158,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
159
158
|
{Array.from({ length: frameCount }, (_, index) => (
|
|
160
159
|
<div
|
|
161
160
|
key={index}
|
|
162
|
-
className="relative h-full
|
|
161
|
+
className="relative h-full shrink-0 overflow-hidden"
|
|
163
162
|
style={{ width: frameW }}
|
|
164
163
|
>
|
|
165
164
|
<img
|
|
@@ -168,7 +168,7 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
168
168
|
}}
|
|
169
169
|
placeholder="What should change?"
|
|
170
170
|
rows={2}
|
|
171
|
-
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-
|
|
171
|
+
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-hidden focus:border-studio-accent/40 transition-colors"
|
|
172
172
|
/>
|
|
173
173
|
</div>
|
|
174
174
|
|
|
@@ -73,7 +73,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
73
73
|
{Array.from({ length: frameCount }, (_, index) => (
|
|
74
74
|
<div
|
|
75
75
|
key={index}
|
|
76
|
-
className="relative h-full
|
|
76
|
+
className="relative h-full shrink-0 overflow-hidden bg-neutral-900"
|
|
77
77
|
style={{ width: frameW }}
|
|
78
78
|
>
|
|
79
79
|
<img
|
|
@@ -40,9 +40,9 @@ interface KeyframeDiamondContextMenuProps {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const ITEM_CLS =
|
|
43
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-
|
|
43
|
+
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
|
|
44
44
|
const DESTRUCTIVE_ITEM_CLS =
|
|
45
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-
|
|
45
|
+
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
|
|
46
46
|
|
|
47
47
|
export function KeyframeDiamondContextMenu({
|
|
48
48
|
state,
|
|
@@ -96,7 +96,7 @@ export function KeyframeDiamondContextMenu({
|
|
|
96
96
|
ref={menuRef}
|
|
97
97
|
role="menu"
|
|
98
98
|
aria-label="Keyframe actions"
|
|
99
|
-
className="fixed z-
|
|
99
|
+
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
|
|
100
100
|
style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
|
|
101
101
|
>
|
|
102
102
|
{onMoveToPlayhead && (
|
|
@@ -172,7 +172,7 @@ export function KeyframeDiamondContextMenu({
|
|
|
172
172
|
<button
|
|
173
173
|
type="button"
|
|
174
174
|
role="menuitem"
|
|
175
|
-
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-950/40 focus-visible:bg-red-950/40 outline-
|
|
175
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-950/40 focus-visible:bg-red-950/40 outline-hidden cursor-pointer text-left"
|
|
176
176
|
onClick={() => {
|
|
177
177
|
onDeleteAll(state.element, state.animationId);
|
|
178
178
|
onClose();
|