@hyperframes/studio 0.8.50 → 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-CxxDxHTW.js → hyperframes-player-Cn_PpHqP.js} +3 -3
- package/dist/assets/index-C05SNrvY.js +360 -0
- package/dist/assets/{index-DUAIWxkI.js → index-CPS3Cvj8.js} +1 -1
- package/dist/assets/{index-Dt3qBI-9.js → index-Clrb11lj.js} +1 -1
- package/dist/assets/index-DUR9qzQM.css +1 -0
- package/dist/index.d.ts +18 -13
- package/dist/index.html +2 -2
- package/dist/index.js +1867 -3335
- 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 +128 -141
- 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.tsx +4 -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 +1 -1
- 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/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.tsx +2 -2
- 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 +4 -3
- package/src/components/editor/propertyPanelColor.tsx +11 -11
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
- package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
- package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFill.tsx +4 -4
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
- package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
- package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
- package/src/components/editor/propertyPanelFont.tsx +5 -5
- package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.tsx +6 -6
- package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
- package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
- package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
- package/src/components/editor/propertyPanelSections.tsx +8 -8
- package/src/components/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.tsx +3 -3
- package/src/components/nle/PreviewOverlays.tsx +6 -6
- package/src/components/nle/PreviewPane.tsx +2 -2
- package/src/components/nle/TimelinePane.tsx +3 -3
- package/src/components/nle/TimelineResizeDivider.tsx +2 -2
- 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/useAppHotkeys.ts +1 -1
- package/src/hooks/useLintModal.test.tsx +65 -0
- package/src/hooks/useLintModal.ts +3 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
- package/src/hooks/useSdkSession.ts +43 -10
- package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
- package/src/hooks/useTimelineAssetDropOps.ts +52 -11
- 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 +3 -3
- package/src/player/components/CompositionThumbnail.test.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +3 -4
- package/src/player/components/EditModal.tsx +1 -1
- package/src/player/components/ImageThumbnail.tsx +1 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
- package/src/player/components/LayerDisclosureRow.tsx +2 -2
- package/src/player/components/PlayerControls.tsx +1 -1
- package/src/player/components/ShortcutsPanel.tsx +9 -9
- package/src/player/components/SpeedMenu.tsx +3 -5
- package/src/player/components/Timeline.tsx +2 -1
- package/src/player/components/TimelineCanvas.tsx +17 -10
- package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
- package/src/player/components/TimelineEmptyState.tsx +5 -5
- package/src/player/components/TimelineFxButton.tsx +6 -6
- package/src/player/components/TimelineGroupHeader.tsx +5 -5
- package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +32 -0
- package/src/player/components/TimelineLanes.tsx +2 -5
- package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +1 -1
- package/src/player/components/TimelineShortcutHint.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
- package/src/player/components/TimelineTypes.ts +2 -1
- package/src/player/components/TrackGapContextMenu.tsx +1 -1
- package/src/player/components/VideoThumbnail.tsx +4 -2
- package/src/player/components/VolumeControl.tsx +2 -2
- package/src/player/components/timelineCallbacks.ts +8 -8
- package/src/player/components/timelineClipDragPreview.ts +51 -27
- package/src/player/components/timelineClipDragTypes.ts +3 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -1
- package/src/player/components/timelineDragDrop.ts +57 -15
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineSnapping.test.ts +27 -0
- package/src/player/components/timelineSnapping.ts +14 -1
- package/src/player/components/trackHeaderLabelRows.tsx +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
- package/src/player/components/useTimelineClipDrag.ts +30 -3
- package/src/player/lib/describeClips.ts +3 -54
- package/src/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/studioUiPreferences.ts +7 -0
- package/src/utils/timelineDropTrackInsert.test.ts +134 -0
- package/src/utils/timelineDropTrackInsert.ts +83 -0
- package/dist/assets/index-n2ml4tUn.js +0 -442
- package/dist/assets/index-pO6p0H27.css +0 -1
- 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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useLintModal } from "./useLintModal";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
let root: Root | null = null;
|
|
11
|
+
let latest: ReturnType<typeof useLintModal> | null = null;
|
|
12
|
+
|
|
13
|
+
function Probe() {
|
|
14
|
+
latest = useLintModal("demo");
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
if (root) act(() => root?.unmount());
|
|
20
|
+
root = null;
|
|
21
|
+
latest = null;
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function mountWithLint(response: () => Promise<unknown>) {
|
|
26
|
+
vi.stubGlobal("fetch", vi.fn(response));
|
|
27
|
+
root = createRoot(document.createElement("div"));
|
|
28
|
+
await act(async () => root?.render(<Probe />));
|
|
29
|
+
await act(async () => {
|
|
30
|
+
await latest?.handleLint();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lintResponse = (findings: Array<{ severity: string; message: string }>) => async () => ({
|
|
35
|
+
json: async () => ({ findings }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("useLintModal hasLintError", () => {
|
|
39
|
+
it("is true when a finding is an error and false for warnings only", async () => {
|
|
40
|
+
await mountWithLint(lintResponse([{ severity: "error", message: "nested" }]));
|
|
41
|
+
expect(latest?.hasLintError).toBe(true);
|
|
42
|
+
act(() => root?.unmount());
|
|
43
|
+
root = null;
|
|
44
|
+
await mountWithLint(lintResponse([{ severity: "warning", message: "soft" }]));
|
|
45
|
+
expect(latest?.hasLintError).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is true for a mixed error and warning list", async () => {
|
|
49
|
+
await mountWithLint(
|
|
50
|
+
lintResponse([
|
|
51
|
+
{ severity: "warning", message: "soft" },
|
|
52
|
+
{ severity: "error", message: "nested" },
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
expect(latest?.hasLintError).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is true when the manual lint itself fails, matching the badge count", async () => {
|
|
59
|
+
await mountWithLint(async () => {
|
|
60
|
+
throw new Error("offline");
|
|
61
|
+
});
|
|
62
|
+
expect(latest?.lintModal).toHaveLength(1);
|
|
63
|
+
expect(latest?.hasLintError).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -96,6 +96,8 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
|
|
|
96
96
|
|
|
97
97
|
const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
|
|
98
98
|
const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
|
|
99
|
+
// Reads the list the badge count reads: the manual result when present, else the background one.
|
|
100
|
+
const hasLintError = (lintModal ?? backgroundFindings).some((f) => f.severity === "error");
|
|
99
101
|
|
|
100
102
|
// Sync lint findings directly to the player store — eliminates the
|
|
101
103
|
// mirroring useEffect that was previously in App.tsx.
|
|
@@ -111,5 +113,6 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
|
|
|
111
113
|
backgroundFindings,
|
|
112
114
|
findingsByElement,
|
|
113
115
|
findingsByFile,
|
|
116
|
+
hasLintError,
|
|
114
117
|
};
|
|
115
118
|
}
|
|
@@ -164,17 +164,62 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
164
164
|
|
|
165
165
|
// Every cutover chokepoint silently takes the server path when there is no
|
|
166
166
|
// session, and the shadow never runs either — so a missing session is a total,
|
|
167
|
-
// otherwise-invisible SDK bypass. These
|
|
168
|
-
|
|
167
|
+
// otherwise-invisible SDK bypass. These exits are its only origin.
|
|
168
|
+
//
|
|
169
|
+
// `stage: read` carries a reason because it was the largest remaining class
|
|
170
|
+
// and a single opaque string: 56 users hit it in a 7-day window and never
|
|
171
|
+
// landed one successful SDK edit between them, with no way to tell a genuinely
|
|
172
|
+
// absent file from a request that never reached one.
|
|
173
|
+
it("reports the HTTP status when the read fails", async () => {
|
|
169
174
|
vi.stubGlobal(
|
|
170
175
|
"fetch",
|
|
171
|
-
vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
|
|
176
|
+
vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }) as Response),
|
|
172
177
|
);
|
|
173
178
|
const root = createRoot(document.createElement("div"));
|
|
174
179
|
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
175
180
|
await flushAsyncEffects();
|
|
176
181
|
|
|
177
|
-
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
182
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
183
|
+
stage: "read",
|
|
184
|
+
reason: "http_error",
|
|
185
|
+
status: 404,
|
|
186
|
+
});
|
|
187
|
+
await act(async () => root.unmount());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("separates an unexpected response shape from a failed request", async () => {
|
|
191
|
+
vi.stubGlobal(
|
|
192
|
+
"fetch",
|
|
193
|
+
vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response),
|
|
194
|
+
);
|
|
195
|
+
const root = createRoot(document.createElement("div"));
|
|
196
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
197
|
+
await flushAsyncEffects();
|
|
198
|
+
|
|
199
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
200
|
+
stage: "read",
|
|
201
|
+
reason: "missing_content",
|
|
202
|
+
});
|
|
203
|
+
await act(async () => root.unmount());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// `optional=1` answers a file that is not on disk with 200 + an empty string,
|
|
207
|
+
// so this is what "the composition genuinely is not there" looks like on the
|
|
208
|
+
// wire — previously indistinguishable from a broken request. The shim and a
|
|
209
|
+
// real 0-byte file are the same response, hence the name.
|
|
210
|
+
it("separates a file that is not on disk", async () => {
|
|
211
|
+
vi.stubGlobal(
|
|
212
|
+
"fetch",
|
|
213
|
+
vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
|
|
214
|
+
);
|
|
215
|
+
const root = createRoot(document.createElement("div"));
|
|
216
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
217
|
+
await flushAsyncEffects();
|
|
218
|
+
|
|
219
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
220
|
+
stage: "read",
|
|
221
|
+
reason: "absent_or_empty",
|
|
222
|
+
});
|
|
178
223
|
await act(async () => root.unmount());
|
|
179
224
|
});
|
|
180
225
|
|
|
@@ -7,25 +7,53 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
|
7
7
|
import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Why an optional project-file read produced no usable content. `stage: "read"`
|
|
11
|
+
* was a single opaque reason covering all of these, which made the largest
|
|
12
|
+
* remaining class of SDK-session failures undiagnosable: 56 users in a 7-day
|
|
13
|
+
* window hit it and, between them, never landed a single successful SDK edit.
|
|
14
|
+
* Knowing which branch fired is the difference between "the file legitimately
|
|
15
|
+
* is not there" and "the request never reached the file".
|
|
16
|
+
*
|
|
17
|
+
* Every reason lives in this union so the full surface is readable from one
|
|
18
|
+
* place — `absent_or_empty` included, even though it is a 2xx.
|
|
19
|
+
*/
|
|
20
|
+
type ProjectFileReadFailure =
|
|
21
|
+
| { ok: false; reason: "unsafe_path" }
|
|
22
|
+
| { ok: false; reason: "http_error"; status: number }
|
|
23
|
+
| { ok: false; reason: "missing_content" }
|
|
24
|
+
| { ok: false; reason: "absent_or_empty" };
|
|
25
|
+
|
|
26
|
+
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read a project file's content (optional read — a missing file is not an
|
|
30
|
+
* error). Replaces the removed SDK http adapter's `read()` — the only thing
|
|
31
|
+
* Studio used it for (Studio is the sole writer, so the adapter's write path
|
|
32
|
+
* was dead).
|
|
13
33
|
*/
|
|
14
34
|
async function readProjectFileOptional(
|
|
15
35
|
projectId: string,
|
|
16
36
|
path: string,
|
|
17
|
-
): Promise<
|
|
37
|
+
): Promise<ProjectFileReadResult> {
|
|
18
38
|
// Reject traversal / NUL before building the request URL — `path` is a
|
|
19
39
|
// user-influenced composition path (mirrors the guard in timelineEditingHelpers,
|
|
20
40
|
// and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
|
|
21
41
|
// already confines both values to single segments of this same-origin URL.
|
|
22
|
-
if (path.includes("\0") || path.includes("..")) return
|
|
42
|
+
if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
|
|
23
43
|
const res = await fetch(
|
|
24
44
|
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
25
45
|
);
|
|
26
|
-
if (!res.ok) return
|
|
46
|
+
if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
|
|
27
47
|
const data = (await res.json()) as { content?: string };
|
|
28
|
-
|
|
48
|
+
// `optional=1` answers a missing file with 200 + `content: ""`, so a
|
|
49
|
+
// non-string here means a response shape we did not expect, not absence.
|
|
50
|
+
if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
|
|
51
|
+
// An empty body parses into a session with no elements, which declines every
|
|
52
|
+
// edit wholesale — not a session worth opening. The absent-file shim and a
|
|
53
|
+
// genuinely 0-byte file are the same 200 on the wire and cannot be told
|
|
54
|
+
// apart here, hence the name; for a composition it is always the former.
|
|
55
|
+
if (data.content === "") return { ok: false, reason: "absent_or_empty" };
|
|
56
|
+
return { ok: true, content: data.content };
|
|
29
57
|
}
|
|
30
58
|
|
|
31
59
|
/**
|
|
@@ -180,16 +208,21 @@ export function useSdkSession(
|
|
|
180
208
|
};
|
|
181
209
|
|
|
182
210
|
readProjectFileOptional(projectId, activeCompPath)
|
|
183
|
-
.then(async (
|
|
211
|
+
.then(async (read) => {
|
|
184
212
|
if (cancelled) return;
|
|
185
|
-
if (
|
|
213
|
+
if (!read.ok) {
|
|
186
214
|
// No SDK session follows, so EVERY cutover chokepoint below takes the
|
|
187
215
|
// server path and emits nothing — the shadow never runs either. This
|
|
188
216
|
// is the only place a missing session can originate, so a broken read
|
|
189
217
|
// would otherwise be a silent, total SDK bypass.
|
|
190
|
-
trackStudioEvent("sdk_session_unavailable", {
|
|
218
|
+
trackStudioEvent("sdk_session_unavailable", {
|
|
219
|
+
stage: "read",
|
|
220
|
+
reason: read.reason,
|
|
221
|
+
...(read.reason === "http_error" ? { status: read.status } : {}),
|
|
222
|
+
});
|
|
191
223
|
return;
|
|
192
224
|
}
|
|
225
|
+
const content = read.content;
|
|
193
226
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
194
227
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
195
228
|
// queue too would double-write the file (queue auto-writes on every
|
|
@@ -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) => (
|
|
@@ -78,7 +78,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
78
78
|
ref={menuRef}
|
|
79
79
|
role="menu"
|
|
80
80
|
aria-label="Clip actions"
|
|
81
|
-
className="fixed z-
|
|
81
|
+
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
82
82
|
style={{ left: adjustedX, top: adjustedY }}
|
|
83
83
|
>
|
|
84
84
|
{splitLabel && (
|
|
@@ -86,7 +86,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
86
86
|
<button
|
|
87
87
|
type="button"
|
|
88
88
|
role="menuitem"
|
|
89
|
-
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 ${
|
|
90
90
|
canSplit
|
|
91
91
|
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
92
92
|
: "text-neutral-600 cursor-not-allowed"
|
|
@@ -159,7 +159,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
159
159
|
<button
|
|
160
160
|
type="button"
|
|
161
161
|
role="menuitem"
|
|
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-
|
|
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"
|
|
163
163
|
onClick={() => {
|
|
164
164
|
onDelete(element);
|
|
165
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();
|
|
@@ -37,7 +37,7 @@ export function LaneToggleButton({
|
|
|
37
37
|
title={`${isExpanded ? "Hide" : "Show"} lanes`}
|
|
38
38
|
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
|
|
39
39
|
// only the hit box grows.
|
|
40
|
-
className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
40
|
+
className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
41
41
|
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
|
42
42
|
}`}
|
|
43
43
|
onPointerDown={(event) => event.stopPropagation()}
|
|
@@ -97,7 +97,7 @@ export function LayerDisclosureRow({
|
|
|
97
97
|
</span>
|
|
98
98
|
{/* Wraps rather than truncating: a truncated name needs a hover to be
|
|
99
99
|
read, which a scanned column cannot rely on. */}
|
|
100
|
-
<span className="min-w-0 flex-1 break-
|
|
100
|
+
<span className="min-w-0 flex-1 wrap-break-word font-medium leading-tight">{name}</span>
|
|
101
101
|
<TrackClipCount clipCount={clipCount} />
|
|
102
102
|
{children}
|
|
103
103
|
{/* Anchored right, on every header that has one: the lane toggle is the
|
|
@@ -116,7 +116,7 @@ const FullscreenButton = memo(function FullscreenButton({
|
|
|
116
116
|
trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
|
|
117
117
|
onToggleFullscreen();
|
|
118
118
|
}}
|
|
119
|
-
className={`flex h-7 w-7
|
|
119
|
+
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors ${
|
|
120
120
|
isFullscreen ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
|
|
121
121
|
}`}
|
|
122
122
|
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|