@hyperframes/studio 0.7.106 → 0.7.108
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-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
- package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -0
- package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
- package/dist/chunk-AZYHQC6V.js.map +1 -0
- package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
- package/dist/index.d.ts +86 -3
- package/dist/index.html +2 -2
- package/dist/index.js +24325 -17831
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.tsx +0 -2
- package/src/components/StudioErrorBoundary.test.tsx +97 -0
- package/src/components/StudioErrorBoundary.tsx +7 -0
- package/src/components/StudioOverlays.tsx +15 -13
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
- package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
- package/src/components/editor/InlineTextToolbar.tsx +281 -0
- package/src/components/editor/OffCanvasIndicators.tsx +38 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/domEditInlineText.test.ts +86 -0
- package/src/components/editor/domEditInlineText.ts +99 -0
- package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.ts +7 -6
- package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
- package/src/components/editor/inlineTextStyleRange.ts +593 -0
- package/src/components/editor/inlineTextStyleRead.ts +47 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
- package/src/components/feedback/feedbackTrigger.test.ts +168 -0
- package/src/components/feedback/feedbackTrigger.ts +269 -0
- package/src/components/feedback/projectProvenance.test.ts +120 -0
- package/src/components/feedback/projectProvenance.ts +83 -0
- package/src/components/renders/useRenderQueue.ts +62 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
- package/src/contexts/DomEditContext.tsx +15 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +31 -4
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +141 -21
- package/src/hooks/useDomEditCommits.ts +4 -0
- package/src/hooks/useDomEditSession.ts +4 -0
- package/src/hooks/useDomEditTextCommits.ts +112 -11
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/breadcrumbs.test.ts +68 -0
- package/src/telemetry/breadcrumbs.ts +70 -0
- package/src/telemetry/client.ts +5 -0
- package/src/telemetry/events.ts +84 -3
- package/src/telemetry/system.ts +8 -0
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/studioTelemetry.ts +16 -10
- package/src/utils/timelineDiscovery.ts +3 -24
- package/src/utils/typingTarget.test.ts +54 -0
- package/src/utils/typingTarget.ts +43 -0
- package/dist/assets/index-DerI0ikN.js +0 -428
- package/dist/assets/index-tBPidglp.css +0 -1
- package/dist/chunk-OBAG3GWK.js.map +0 -1
- package/src/components/StudioFeedbackBar.tsx +0 -217
- /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.108",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/sdk": "0.7.
|
|
50
|
+
"@hyperframes/core": "0.7.108",
|
|
51
|
+
"@hyperframes/parsers": "0.7.108",
|
|
52
|
+
"@hyperframes/player": "0.7.108",
|
|
53
|
+
"@hyperframes/studio-server": "0.7.108",
|
|
54
|
+
"@hyperframes/sdk": "0.7.108"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vite": "^6.4.2",
|
|
69
69
|
"vitest": "^3.2.4",
|
|
70
70
|
"zustand": "^5.0.0",
|
|
71
|
-
"@hyperframes/producer": "0.7.
|
|
71
|
+
"@hyperframes/producer": "0.7.108"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"react": "19",
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
} from "./nle/useTimelineEditCallbacks";
|
|
9
9
|
import { NLEProvider, useNLEContext } from "./nle/NLEContext";
|
|
10
10
|
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
|
11
|
-
import { StudioFeedbackBar } from "./StudioFeedbackBar";
|
|
12
11
|
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
|
13
12
|
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
|
|
14
13
|
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
|
|
@@ -189,7 +188,6 @@ export function EditorShell({
|
|
|
189
188
|
/>
|
|
190
189
|
</NLEProvider>
|
|
191
190
|
</TimelineEditProvider>
|
|
192
|
-
<StudioFeedbackBar />
|
|
193
191
|
</div>
|
|
194
192
|
);
|
|
195
193
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
8
|
+
vi.mock("../telemetry/policy", () => ({ browserTelemetryAllowed: () => true }));
|
|
9
|
+
vi.mock("../telemetry/events", () => ({
|
|
10
|
+
trackStudioFeedback: vi.fn(),
|
|
11
|
+
trackStudioFeedbackShown: vi.fn(),
|
|
12
|
+
trackStudioFeedbackDismissed: vi.fn(),
|
|
13
|
+
trackStudioFeedbackInterviewClick: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const { StudioErrorBoundary } = await import("./StudioErrorBoundary");
|
|
17
|
+
const { trackStudioFeedbackShown } = await import("../telemetry/events");
|
|
18
|
+
|
|
19
|
+
function Boom(): React.ReactElement {
|
|
20
|
+
throw new Error("timeline exploded");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let container: HTMLDivElement;
|
|
24
|
+
let root: ReturnType<typeof createRoot>;
|
|
25
|
+
let consoleError: ReturnType<typeof vi.spyOn>;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
localStorage.clear();
|
|
29
|
+
sessionStorage.clear();
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
container = document.createElement("div");
|
|
32
|
+
document.body.appendChild(container);
|
|
33
|
+
root = createRoot(container);
|
|
34
|
+
// React logs the caught error itself; the boundary is the thing under test.
|
|
35
|
+
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
act(() => root.unmount());
|
|
40
|
+
container.remove();
|
|
41
|
+
consoleError.mockRestore();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const renderCrashed = () =>
|
|
45
|
+
act(() => {
|
|
46
|
+
root.render(
|
|
47
|
+
<StudioErrorBoundary>
|
|
48
|
+
<Boom />
|
|
49
|
+
</StudioErrorBoundary>,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("StudioErrorBoundary", () => {
|
|
54
|
+
it("shows the crash screen with the error message and both recovery paths", () => {
|
|
55
|
+
renderCrashed();
|
|
56
|
+
expect(container.textContent).toContain("Something went wrong");
|
|
57
|
+
expect(container.textContent).toContain("timeline exploded");
|
|
58
|
+
expect(container.textContent).toContain("Try again");
|
|
59
|
+
expect(container.textContent).toContain("Reload Studio");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("asks what the user was doing, since the stack trace cannot say", () => {
|
|
63
|
+
renderCrashed();
|
|
64
|
+
const card = container.querySelector('[aria-label="Send feedback to the HyperFrames team"]');
|
|
65
|
+
expect(card).not.toBeNull();
|
|
66
|
+
expect(card?.textContent).toContain("Studio crashed. What were you doing?");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("offers one-tap answers instead of a 0-10 score", () => {
|
|
70
|
+
renderCrashed();
|
|
71
|
+
const card = container.querySelector('[aria-label="Send feedback to the HyperFrames team"]');
|
|
72
|
+
const chips = [...(card?.querySelectorAll("button") ?? [])]
|
|
73
|
+
.map((b) => b.textContent?.trim())
|
|
74
|
+
.filter((t) => t && t !== "Send");
|
|
75
|
+
expect(chips).toContain("Editing the timeline");
|
|
76
|
+
expect(chips).toContain("Just opened it");
|
|
77
|
+
// Rating a crash is a question with no useful answer.
|
|
78
|
+
expect(card?.querySelector('input[name="hf-studio-feedback-rating"]')).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reports the crash prompt to telemetry so the funnel is visible", () => {
|
|
82
|
+
renderCrashed();
|
|
83
|
+
expect(trackStudioFeedbackShown).toHaveBeenCalledWith(
|
|
84
|
+
expect.objectContaining({ reason: "crash" }),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("stays quiet when the user already gave feedback recently", () => {
|
|
89
|
+
localStorage.setItem("hyperframes-studio:feedbackAnsweredAt", String(Date.now()));
|
|
90
|
+
renderCrashed();
|
|
91
|
+
// The crash screen itself still works; only the ask is suppressed.
|
|
92
|
+
expect(container.textContent).toContain("Something went wrong");
|
|
93
|
+
expect(
|
|
94
|
+
container.querySelector('[aria-label="Send feedback to the HyperFrames team"]'),
|
|
95
|
+
).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
2
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
3
|
+
import { CrashFeedbackPrompt } from "./feedback/CrashFeedbackPrompt";
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
5
6
|
children: ReactNode;
|
|
@@ -51,6 +52,12 @@ export class StudioErrorBoundary extends Component<Props, State> {
|
|
|
51
52
|
Reload Studio
|
|
52
53
|
</button>
|
|
53
54
|
</div>
|
|
55
|
+
{/* The crash report tells us what broke; only the user can tell us what
|
|
56
|
+
they were doing when it did. This is also the one screen where they
|
|
57
|
+
have nothing else to get on with. */}
|
|
58
|
+
<div className="mt-6">
|
|
59
|
+
<CrashFeedbackPrompt />
|
|
60
|
+
</div>
|
|
54
61
|
</div>
|
|
55
62
|
);
|
|
56
63
|
}
|
|
@@ -3,6 +3,7 @@ import { LintModal } from "./LintModal";
|
|
|
3
3
|
import { AskAgentModal } from "./AskAgentModal";
|
|
4
4
|
import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
|
|
5
5
|
import { StudioToast } from "./StudioToast";
|
|
6
|
+
import { StudioFeedbackCard } from "./feedback/StudioFeedbackCard";
|
|
6
7
|
import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
|
|
7
8
|
import type { useDomEditSession } from "../hooks/useDomEditSession";
|
|
8
9
|
import type { useToast } from "../hooks/useToast";
|
|
@@ -78,19 +79,20 @@ export function StudioOverlays({
|
|
|
78
79
|
/>
|
|
79
80
|
)}
|
|
80
81
|
{dragOverlayActive && <StudioGlobalDragOverlay />}
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
82
|
+
{/* One bottom-right stack so the feedback card and toasts queue instead
|
|
83
|
+
of covering each other. Empty when nothing is showing. */}
|
|
84
|
+
<div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
|
|
85
|
+
{toasts.map((toast) => (
|
|
86
|
+
<StudioToast
|
|
87
|
+
key={toast.id}
|
|
88
|
+
message={toast.message}
|
|
89
|
+
tone={toast.tone}
|
|
90
|
+
leaving={toast.leaving}
|
|
91
|
+
onDismiss={() => dismissToast(toast.id)}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
<StudioFeedbackCard />
|
|
95
|
+
</div>
|
|
94
96
|
</>
|
|
95
97
|
);
|
|
96
98
|
}
|
|
@@ -124,6 +124,7 @@ export function StudioRightPanel({
|
|
|
124
124
|
handleDomStyleCommit,
|
|
125
125
|
handleDomAttributeCommit,
|
|
126
126
|
handleDomAttributeLiveCommit,
|
|
127
|
+
handleDomAttributeQuietCommit,
|
|
127
128
|
handleDomHtmlAttributeCommit,
|
|
128
129
|
handleDomAttributesCommit,
|
|
129
130
|
handleDomPathOffsetCommit,
|
|
@@ -326,6 +327,21 @@ export function StudioRightPanel({
|
|
|
326
327
|
},
|
|
327
328
|
[projectId, refreshFileTree, showToast],
|
|
328
329
|
);
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* A dial being dragged writes to the preview and stops there.
|
|
333
|
+
*
|
|
334
|
+
* Every one of these panels previews on each pointermove and commits on
|
|
335
|
+
* release. Persisting the moves too put a fragment of the drag in the undo
|
|
336
|
+
* stack — and since those writes race, history could not coalesce them
|
|
337
|
+
* reliably, so undo took back a sliver of the gesture rather than the gesture.
|
|
338
|
+
* The release's own commit is what reaches the file and the undo stack.
|
|
339
|
+
*/
|
|
340
|
+
const setAttributeWhileDragging = useCallback(
|
|
341
|
+
(attr: string, value: string | null) =>
|
|
342
|
+
handleDomAttributeLiveCommit(attr, value, undefined, { previewOnly: true }),
|
|
343
|
+
[handleDomAttributeLiveCommit],
|
|
344
|
+
);
|
|
329
345
|
const handleHideAllSelected = () => {
|
|
330
346
|
const { elements } = usePlayerStore.getState();
|
|
331
347
|
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
|
|
@@ -360,7 +376,8 @@ export function StudioRightPanel({
|
|
|
360
376
|
onSetStyle={handleDomStyleCommit}
|
|
361
377
|
onSetAttribute={handleDomAttributeCommit}
|
|
362
378
|
onSetAttributes={handleDomAttributesCommit}
|
|
363
|
-
onSetAttributeLive={
|
|
379
|
+
onSetAttributeLive={setAttributeWhileDragging}
|
|
380
|
+
onSetAttributeQuiet={handleDomAttributeQuietCommit}
|
|
364
381
|
onApplyColorGradingScope={handleApplyColorGradingScope}
|
|
365
382
|
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
|
|
366
383
|
onRemoveBackground={handleRemoveBackground}
|
|
@@ -118,3 +118,49 @@ describe("TimelineToolbar — motion path endpoints", () => {
|
|
|
118
118
|
act(() => root.unmount());
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
describe("TimelineToolbar — keyframes on audio tracks", () => {
|
|
123
|
+
const clip = (tag: string) => ({
|
|
124
|
+
id: "bgm",
|
|
125
|
+
key: "bgm",
|
|
126
|
+
tag,
|
|
127
|
+
start: 0,
|
|
128
|
+
duration: 10,
|
|
129
|
+
track: 1,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** A session whose selection would otherwise offer the keyframe toggle. */
|
|
133
|
+
function sessionFor(tag: string) {
|
|
134
|
+
usePlayerStore.setState({ elements: [clip(tag)], selectedElementId: "bgm", currentTime: 1 });
|
|
135
|
+
const element = document.createElement(tag);
|
|
136
|
+
element.id = "bgm";
|
|
137
|
+
return {
|
|
138
|
+
domEditSelection: makeSelection("Element", element),
|
|
139
|
+
selectedGsapAnimations: [],
|
|
140
|
+
handleGsapAddAnimation: vi.fn(),
|
|
141
|
+
handleGsapConvertToKeyframes: vi.fn(),
|
|
142
|
+
handleGsapRemoveKeyframe: vi.fn(),
|
|
143
|
+
} satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it("offers no keyframe toggle for an audio clip", () => {
|
|
147
|
+
// An audio clip has no box on the canvas, so there is nothing to move or fade —
|
|
148
|
+
// and pressing this seeded a tween from the position properties, which put a
|
|
149
|
+
// position lane on a track that has no position. Audio is automated instead.
|
|
150
|
+
const { host, root } = renderToolbar(sessionFor("audio"));
|
|
151
|
+
const button = host.querySelector<HTMLButtonElement>(
|
|
152
|
+
'button[aria-label="Add keyframe at playhead"]',
|
|
153
|
+
);
|
|
154
|
+
expect(button?.disabled).toBe(true);
|
|
155
|
+
act(() => root.unmount());
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("still offers it for a visual clip", () => {
|
|
159
|
+
const { host, root } = renderToolbar(sessionFor("div"));
|
|
160
|
+
const button = host.querySelector<HTMLButtonElement>(
|
|
161
|
+
'button[aria-label="Add keyframe at playhead"]',
|
|
162
|
+
);
|
|
163
|
+
expect(button?.disabled).toBe(false);
|
|
164
|
+
act(() => root.unmount());
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -86,8 +86,22 @@ function resolveKeyframeToggleState(
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Can this element be keyframed at all?
|
|
91
|
+
*
|
|
92
|
+
* An audio clip cannot. It has no box on the canvas, so there is nothing to move,
|
|
93
|
+
* scale or fade — and "add a keyframe" on one seeds a tween from the position
|
|
94
|
+
* properties, which produced a position lane on a track that has no position. Audio
|
|
95
|
+
* is automated instead: volume and effect parameters, on their own lanes.
|
|
96
|
+
*/
|
|
97
|
+
function isKeyframeable(element: TimelineElement | undefined): boolean {
|
|
98
|
+
return element?.tag !== "audio";
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
90
102
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
103
|
+
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
104
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
91
105
|
const sessionRef = useRef(session);
|
|
92
106
|
sessionRef.current = session;
|
|
93
107
|
|
|
@@ -95,6 +109,9 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
|
95
109
|
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
|
|
96
110
|
);
|
|
97
111
|
|
|
112
|
+
const selected = elements.find((element) => (element.key ?? element.id) === selectedElementId);
|
|
113
|
+
if (!isKeyframeable(selected)) return { ...NO_KEYFRAME_TOGGLE, onToggle: undefined };
|
|
114
|
+
|
|
98
115
|
const toggleState = resolveKeyframeToggleState(session, currentTime);
|
|
99
116
|
|
|
100
117
|
return {
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
resolveShiftClickCandidate,
|
|
17
17
|
} from "./domEditOverlayGestures";
|
|
18
18
|
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
|
|
19
|
-
import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
|
|
19
|
+
import { ChildRectOutlines, OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
|
|
20
20
|
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
21
21
|
import { useDomEditNudge } from "./useDomEditNudge";
|
|
22
22
|
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
|
|
@@ -30,6 +30,7 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
|
30
30
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
31
31
|
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
|
32
32
|
import { CanvasContextMenu } from "./CanvasContextMenu";
|
|
33
|
+
import { useInlineTextEditing } from "./useInlineTextEditing";
|
|
33
34
|
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
|
34
35
|
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
|
35
36
|
import { logSelect } from "../../utils/selectDebug";
|
|
@@ -164,6 +165,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
164
165
|
groupSelectionsRef.current = groupSelections;
|
|
165
166
|
const hoverSelectionRef = useRef(hoverSelection);
|
|
166
167
|
hoverSelectionRef.current = hoverSelection;
|
|
168
|
+
|
|
169
|
+
// Double-click an element to edit its text where it sits.
|
|
170
|
+
const inlineText = useInlineTextEditing(selectionRef);
|
|
167
171
|
const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
|
|
168
172
|
onPathOffsetCommitRef.current = onPathOffsetCommit;
|
|
169
173
|
const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
|
|
@@ -370,6 +374,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
370
374
|
return;
|
|
371
375
|
}
|
|
372
376
|
|
|
377
|
+
// A second press on the same spot opens that element's text. This is the
|
|
378
|
+
// press path that actually runs: the pointer handler prevents the default
|
|
379
|
+
// on its way through, so the overlay's own mousedown never fires, and the
|
|
380
|
+
// browser never pairs the presses into a dblclick either.
|
|
381
|
+
if (inlineText.startFromPress(event)) {
|
|
382
|
+
event.preventDefault();
|
|
383
|
+
event.stopPropagation();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
373
387
|
const target = event.target as HTMLElement | null;
|
|
374
388
|
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
|
375
389
|
|
|
@@ -449,7 +463,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
449
463
|
return (
|
|
450
464
|
<div
|
|
451
465
|
ref={overlayRef}
|
|
452
|
-
|
|
466
|
+
// Standing aside is the only way the caret below can be reached, and is
|
|
467
|
+
// what keeps selection, drag and marquee from firing mid-edit.
|
|
468
|
+
className={`absolute inset-0 z-10 outline-none ${
|
|
469
|
+
inlineText.editing ? "pointer-events-none" : "pointer-events-auto"
|
|
470
|
+
}`}
|
|
471
|
+
data-editing-text={inlineText.editing ? "true" : undefined}
|
|
453
472
|
tabIndex={-1}
|
|
454
473
|
aria-label="Composition canvas"
|
|
455
474
|
// Cursor follows marquee rect *state* (re-renders), not the mutable ref.
|
|
@@ -458,7 +477,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
458
477
|
// A pointer gesture supersedes a pending nudge burst — commit it first
|
|
459
478
|
// so the gesture's member snapshot starts from the nudged position.
|
|
460
479
|
flushNudge();
|
|
461
|
-
|
|
480
|
+
// Not while editing: taking focus back would send the keystroke nowhere.
|
|
481
|
+
if (!inlineText.editing) {
|
|
482
|
+
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay);
|
|
483
|
+
}
|
|
484
|
+
}}
|
|
485
|
+
onKeyDown={(event) => {
|
|
486
|
+
if (!inlineText.handleKeyDown(event)) return;
|
|
487
|
+
event.preventDefault();
|
|
488
|
+
event.stopPropagation();
|
|
462
489
|
}}
|
|
463
490
|
onPointerDown={handleOverlayPointerDown}
|
|
464
491
|
onMouseDown={handleOverlayMouseDown}
|
|
@@ -492,6 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
492
519
|
)}
|
|
493
520
|
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
|
|
494
521
|
<DomEditSelectionChrome
|
|
522
|
+
inlineText={inlineText}
|
|
495
523
|
selection={selection}
|
|
496
524
|
overlayRect={overlayRect}
|
|
497
525
|
allowCanvasMovement={allowCanvasMovement}
|
|
@@ -508,21 +536,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
508
536
|
onBoxClick={handleBoxClick}
|
|
509
537
|
/>
|
|
510
538
|
)}
|
|
511
|
-
{
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
className="pointer-events-none absolute border border-dashed border-white/20 rounded-sm"
|
|
517
|
-
style={{
|
|
518
|
-
left: cr.left,
|
|
519
|
-
top: cr.top,
|
|
520
|
-
width: cr.width,
|
|
521
|
-
height: cr.height,
|
|
522
|
-
transform: cr.angle ? `rotate(${cr.angle}deg)` : undefined,
|
|
523
|
-
}}
|
|
524
|
-
/>
|
|
525
|
-
))}
|
|
539
|
+
<ChildRectOutlines rects={compRect.width > 0 ? childRects : []} />
|
|
540
|
+
{/* Mounted here rather than with the selection chrome: the chrome does
|
|
541
|
+
not render for every selection, and the toolbar belongs to the
|
|
542
|
+
editing session, which does. */}
|
|
543
|
+
{inlineText.toolbar}
|
|
526
544
|
<OffCanvasIndicators
|
|
527
545
|
rects={offCanvasRects}
|
|
528
546
|
elements={offCanvasElementsRef}
|
|
@@ -132,3 +132,88 @@ describe("DomEditSelectionChrome crop composition", () => {
|
|
|
132
132
|
act(() => root.unmount());
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
// The bug: the overlay above the preview goes pointer-events-none while text is
|
|
137
|
+
// being edited, but `pointer-events: none` on a parent does not disable a child
|
|
138
|
+
// that sets `auto`. The selection box covers exactly the element being typed
|
|
139
|
+
// into, so it kept swallowing every press: the caret could only ever be placed
|
|
140
|
+
// once, when the edit opened, and dragging across characters did nothing.
|
|
141
|
+
describe("DomEditSelectionChrome while editing text", () => {
|
|
142
|
+
const CAPABLE = {
|
|
143
|
+
canCrop: true,
|
|
144
|
+
canApplyManualOffset: true,
|
|
145
|
+
canApplyManualSize: true,
|
|
146
|
+
canApplyManualRotation: true,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function renderChrome(editing: boolean) {
|
|
150
|
+
const element = document.createElement("div");
|
|
151
|
+
element.id = "copy";
|
|
152
|
+
document.body.append(element);
|
|
153
|
+
const selection = {
|
|
154
|
+
element,
|
|
155
|
+
id: "copy",
|
|
156
|
+
selector: "#copy",
|
|
157
|
+
capabilities: CAPABLE,
|
|
158
|
+
} as unknown as DomEditSelection;
|
|
159
|
+
const host = document.createElement("div");
|
|
160
|
+
document.body.append(host);
|
|
161
|
+
const root = createRoot(host);
|
|
162
|
+
act(() => {
|
|
163
|
+
root.render(
|
|
164
|
+
<DomEditSelectionChrome
|
|
165
|
+
selection={selection}
|
|
166
|
+
overlayRect={{ left: 10, top: 20, width: 200, height: 60, editScaleX: 1, editScaleY: 1 }}
|
|
167
|
+
allowCanvasMovement={true}
|
|
168
|
+
boxRef={createRef()}
|
|
169
|
+
boxChromeClass="border border-studio-accent/80"
|
|
170
|
+
boxClipPath={undefined}
|
|
171
|
+
selectionKey="copy"
|
|
172
|
+
groupSelectionCount={0}
|
|
173
|
+
blockedMoveRef={createRef()}
|
|
174
|
+
gestures={{ startGesture: vi.fn() } as never}
|
|
175
|
+
onStyleCommit={vi.fn()}
|
|
176
|
+
onBoxMouseDown={vi.fn()}
|
|
177
|
+
onBoxClick={vi.fn()}
|
|
178
|
+
inlineText={{ editing, startFromPress: vi.fn() }}
|
|
179
|
+
/>,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
return { host, unmount: () => act(() => root.unmount()) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
it("stops the selection box taking presses, so they reach the caret below", () => {
|
|
186
|
+
const { host, unmount } = renderChrome(true);
|
|
187
|
+
const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
|
|
188
|
+
expect(box.className).toContain("pointer-events-none");
|
|
189
|
+
expect(box.className).not.toContain("pointer-events-auto");
|
|
190
|
+
unmount();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("keeps the box interactive when no text is being edited", () => {
|
|
194
|
+
const { host, unmount } = renderChrome(false);
|
|
195
|
+
const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
|
|
196
|
+
expect(box.className).toContain("pointer-events-auto");
|
|
197
|
+
unmount();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("still marks the edited element, so it is clear which one has the caret", () => {
|
|
201
|
+
const { host, unmount } = renderChrome(true);
|
|
202
|
+
const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
|
|
203
|
+
expect(box.className).toContain("border-studio-accent/80");
|
|
204
|
+
unmount();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("takes away every handle that would sit over the text", () => {
|
|
208
|
+
const { host, unmount } = renderChrome(true);
|
|
209
|
+
expect(host.querySelectorAll(".pointer-events-auto")).toHaveLength(0);
|
|
210
|
+
expect(host.querySelector("[data-dom-edit-crop-frame]")).toBeNull();
|
|
211
|
+
unmount();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("keeps the handles when nothing is being edited", () => {
|
|
215
|
+
const { host, unmount } = renderChrome(false);
|
|
216
|
+
expect(host.querySelectorAll(".pointer-events-auto").length).toBeGreaterThan(1);
|
|
217
|
+
unmount();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -126,6 +126,12 @@ interface DomEditSelectionChromeProps {
|
|
|
126
126
|
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
|
127
127
|
onBoxMouseDown: (e: React.MouseEvent) => void;
|
|
128
128
|
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
129
|
+
/** The canvas' text-editing session: what opens one, and whether one is open. */
|
|
130
|
+
inlineText?: {
|
|
131
|
+
editing: boolean;
|
|
132
|
+
/** Every press on the box. Returns true when it opened a text edit. */
|
|
133
|
+
startFromPress: (event: React.PointerEvent) => boolean;
|
|
134
|
+
};
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
// Oriented selection chrome: a rotation wrapper spanning the overlay, rotated by
|
|
@@ -149,7 +155,16 @@ export function DomEditSelectionChrome({
|
|
|
149
155
|
onStyleCommit,
|
|
150
156
|
onBoxMouseDown,
|
|
151
157
|
onBoxClick,
|
|
158
|
+
inlineText,
|
|
152
159
|
}: DomEditSelectionChromeProps) {
|
|
160
|
+
// While the text is being edited the chrome is a mark, not a control. The
|
|
161
|
+
// overlay above the preview already stands aside for the caret, but
|
|
162
|
+
// `pointer-events: none` on a parent does not disable a child that asks for
|
|
163
|
+
// them back, and the box is positioned to cover exactly the text being typed
|
|
164
|
+
// into: left interactive, it swallows every press, so the caret can never be
|
|
165
|
+
// moved and characters can never be selected by dragging.
|
|
166
|
+
const editing = inlineText?.editing ?? false;
|
|
167
|
+
|
|
153
168
|
return (
|
|
154
169
|
<>
|
|
155
170
|
<div
|
|
@@ -159,7 +174,7 @@ export function DomEditSelectionChrome({
|
|
|
159
174
|
transform: overlayRect.angle ? `rotate(${overlayRect.angle}deg)` : undefined,
|
|
160
175
|
}}
|
|
161
176
|
>
|
|
162
|
-
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
|
|
177
|
+
{allowCanvasMovement && !editing && selection.capabilities.canApplyManualRotation && (
|
|
163
178
|
<DomEditRotateHandle
|
|
164
179
|
overlayRect={overlayRect}
|
|
165
180
|
cropOutlineInsetPx={cropOutlineInsetPx}
|
|
@@ -173,7 +188,7 @@ export function DomEditSelectionChrome({
|
|
|
173
188
|
key={selectionKey}
|
|
174
189
|
ref={boxRef}
|
|
175
190
|
data-dom-edit-selection-box="true"
|
|
176
|
-
className={
|
|
191
|
+
className={`${editing ? "pointer-events-none" : "pointer-events-auto"} absolute rounded-md ${boxChromeClass}`}
|
|
177
192
|
style={{
|
|
178
193
|
left: overlayRect.left,
|
|
179
194
|
top: overlayRect.top,
|
|
@@ -186,6 +201,16 @@ export function DomEditSelectionChrome({
|
|
|
186
201
|
: "default",
|
|
187
202
|
}}
|
|
188
203
|
onPointerDown={(e) => {
|
|
204
|
+
// A second press opens the element's text for editing, and must be
|
|
205
|
+
// caught here rather than on the canvas: this handler prevents the
|
|
206
|
+
// default on the first press, which suppresses the compatibility
|
|
207
|
+
// mousedown the canvas would otherwise see, and the pointer capture
|
|
208
|
+
// it takes stops the browser pairing the presses into a dblclick.
|
|
209
|
+
if (inlineText?.startFromPress(e)) {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
e.stopPropagation();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
189
214
|
if (!allowCanvasMovement || e.shiftKey) return;
|
|
190
215
|
if (selection.capabilities.canApplyManualOffset) {
|
|
191
216
|
gestures.startGesture("drag", e);
|
|
@@ -221,6 +246,7 @@ export function DomEditSelectionChrome({
|
|
|
221
246
|
is positioned relative to the overlay container using the
|
|
222
247
|
overlayRect origin, matching the old child-relative offsets. */}
|
|
223
248
|
{allowCanvasMovement &&
|
|
249
|
+
!editing &&
|
|
224
250
|
selection.capabilities.canApplyManualSize &&
|
|
225
251
|
RESIZE_HANDLE_DEFS.map((def) =>
|
|
226
252
|
def.handle !== "se" && !selection.capabilities.canApplyManualOffset ? null : (
|
|
@@ -245,7 +271,7 @@ export function DomEditSelectionChrome({
|
|
|
245
271
|
</div>
|
|
246
272
|
{/* Crop owns its element-local oriented frame. Keep it outside the chrome's
|
|
247
273
|
rotated plane or a rotated selection applies the angle twice. */}
|
|
248
|
-
{selection.capabilities.canCrop && groupSelectionCount <= 1 && (
|
|
274
|
+
{selection.capabilities.canCrop && !editing && groupSelectionCount <= 1 && (
|
|
249
275
|
<DomEditCropHandles
|
|
250
276
|
selection={selection}
|
|
251
277
|
overlayRect={overlayRect}
|