@hyperframes/studio 0.8.15 → 0.8.16
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-iAIHATIw.js +459 -0
- package/dist/assets/{index-CVtYYTnA.js → index-Cf-mbMRL.js} +1 -1
- package/dist/assets/{index-DKoZIpw1.js → index-D8o3ZIo2.js} +163 -163
- package/dist/assets/{index-CtDg2lB7.js → index-YmetcS6L.js} +1 -1
- package/dist/index.d.ts +8 -12
- package/dist/index.html +1 -1
- package/dist/index.js +24 -6
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/sidebar/AudioRow.tsx +13 -2
- package/src/hooks/useDomSelection.test.ts +48 -0
- package/src/hooks/useDomSelection.ts +26 -82
- package/src/hooks/useDomSelectionTypes.ts +82 -0
- package/src/hooks/usePanelLayout.test.ts +38 -0
- package/src/hooks/usePanelLayout.ts +21 -6
- package/src/player/lib/playbackTypes.ts +3 -12
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/dist/assets/hyperframes-player-BlIp1Y-b.js +0 -459
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.16",
|
|
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/core": "0.8.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/studio-server": "0.8.
|
|
54
|
-
"@hyperframes/sdk": "0.8.
|
|
50
|
+
"@hyperframes/core": "0.8.16",
|
|
51
|
+
"@hyperframes/parsers": "0.8.16",
|
|
52
|
+
"@hyperframes/player": "0.8.16",
|
|
53
|
+
"@hyperframes/studio-server": "0.8.16",
|
|
54
|
+
"@hyperframes/sdk": "0.8.16"
|
|
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.8.
|
|
71
|
+
"@hyperframes/producer": "0.8.16"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"react": "19",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { classifyWebAudioMediaRoute } from "@hyperframes/core/runtime/web-audio-route";
|
|
2
3
|
import { ContextMenu } from "./AssetContextMenu";
|
|
3
4
|
import { basename, getAudioSubtype, type CopyFeedback } from "./assetHelpers";
|
|
4
5
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
@@ -167,14 +168,24 @@ export function AudioRow({
|
|
|
167
168
|
setPlaying(false);
|
|
168
169
|
cancelAnimationFrame(animRef.current);
|
|
169
170
|
};
|
|
171
|
+
// `src` must be set BEFORE classifying: the check reads `currentSrc`/
|
|
172
|
+
// `src`, and a same-origin `serveUrl` mustn't be judged from a blank
|
|
173
|
+
// element.
|
|
174
|
+
el.src = serveUrl;
|
|
170
175
|
audioRef.current = el;
|
|
171
176
|
const analyser = analyserRef.current;
|
|
172
|
-
|
|
177
|
+
// Same hazard `webAudioRoute.ts` documents for the timeline runtime
|
|
178
|
+
// (#3458): `createMediaElementSource` on a cross-origin element without
|
|
179
|
+
// a `crossorigin` opt-in permanently reroutes it to a node that outputs
|
|
180
|
+
// SILENCE per the Web Audio spec, without throwing. Classify first so
|
|
181
|
+
// this preview player can't reintroduce that bug — skipping the Web
|
|
182
|
+
// Audio graph here only costs the frequency-bar visualizer; native
|
|
183
|
+
// `<audio>` playback below stays audible either way.
|
|
184
|
+
if (analyser && classifyWebAudioMediaRoute(el).kind === "web-audio") {
|
|
173
185
|
sourceRef.current = actxRef.current.createMediaElementSource(el);
|
|
174
186
|
sourceRef.current.connect(analyser);
|
|
175
187
|
analyser.connect(actxRef.current.destination);
|
|
176
188
|
}
|
|
177
|
-
el.src = serveUrl;
|
|
178
189
|
}
|
|
179
190
|
|
|
180
191
|
if (actxRef.current.state === "suspended") await actxRef.current.resume();
|
|
@@ -208,6 +208,54 @@ describe("useDomSelection additive", () => {
|
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
describe("useDomSelection", () => {
|
|
211
|
+
it("ignores a repeated non-additive selection of the same target", () => {
|
|
212
|
+
const element = document.createElement("div");
|
|
213
|
+
element.id = "headline";
|
|
214
|
+
const first = makeSelection("Headline", element);
|
|
215
|
+
const repeated = makeSelection("Headline", element);
|
|
216
|
+
const harness = renderHarness({
|
|
217
|
+
activeCompPath: "intro.html",
|
|
218
|
+
projectId: "project-1",
|
|
219
|
+
refreshKey: 0,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
act(() => harness.current().applyDomSelection(first));
|
|
223
|
+
harness.timeline.setSelectedTimelineElementId.mockClear();
|
|
224
|
+
harness.timeline.setTimelineSelectionSet.mockClear();
|
|
225
|
+
|
|
226
|
+
act(() => harness.current().applyDomSelection(repeated));
|
|
227
|
+
|
|
228
|
+
expect(harness.current().domEditSelection).toBe(first);
|
|
229
|
+
expect(harness.current().domEditGroupSelections).toEqual([first]);
|
|
230
|
+
expect(harness.timeline.setSelectedTimelineElementId).not.toHaveBeenCalled();
|
|
231
|
+
expect(harness.timeline.setTimelineSelectionSet).not.toHaveBeenCalled();
|
|
232
|
+
harness.cleanup();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("still refreshes selection data for the same target when preserving the group", () => {
|
|
236
|
+
const element = document.createElement("div");
|
|
237
|
+
element.id = "headline";
|
|
238
|
+
const first = makeSelection("Headline", element);
|
|
239
|
+
const refreshed = makeSelection("Updated headline", element);
|
|
240
|
+
const harness = renderHarness({
|
|
241
|
+
activeCompPath: "intro.html",
|
|
242
|
+
projectId: "project-1",
|
|
243
|
+
refreshKey: 0,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
act(() => harness.current().applyDomSelection(first));
|
|
247
|
+
act(() =>
|
|
248
|
+
harness.current().applyDomSelection(refreshed, {
|
|
249
|
+
preserveGroup: true,
|
|
250
|
+
revealPanel: false,
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(harness.current().domEditSelection).toBe(refreshed);
|
|
255
|
+
expect(harness.current().domEditGroupSelections).toEqual([refreshed]);
|
|
256
|
+
harness.cleanup();
|
|
257
|
+
});
|
|
258
|
+
|
|
211
259
|
it("clears a committed selection when the active composition path changes", () => {
|
|
212
260
|
const { selection, harness } = setupSelectedHarness();
|
|
213
261
|
expect(harness.current().domEditSelection).toBe(selection);
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
3
|
import {
|
|
4
4
|
getAllPreviewTargetsFromPointer,
|
|
5
5
|
getPreviewTargetFromPointer,
|
|
6
6
|
} from "../utils/studioPreviewHelpers";
|
|
7
|
-
import { type RightPanelTab } from "../utils/studioHelpers";
|
|
8
7
|
import {
|
|
9
8
|
domEditSelectionsTargetSame,
|
|
10
9
|
domEditSelectionInGroup,
|
|
@@ -22,86 +21,18 @@ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"
|
|
|
22
21
|
import { useStudioTestHooks } from "./useStudioTestHooks";
|
|
23
22
|
import { logSelect } from "../utils/selectDebug";
|
|
24
23
|
import { announceTimelineSelection as announceSelectionToTimeline } from "./domSelectionTimelineMirror";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
export interface ResolveDomSelectionOptions {
|
|
38
|
-
preferClipAncestor?: boolean;
|
|
39
|
-
skipSourceProbe?: boolean;
|
|
40
|
-
activeGroupElement?: HTMLElement | null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface UseDomSelectionParams {
|
|
44
|
-
projectId: string | null;
|
|
45
|
-
activeCompPath: string | null;
|
|
46
|
-
isMasterView: boolean;
|
|
47
|
-
compIdToSrc: Map<string, string>;
|
|
48
|
-
captionEditMode: boolean;
|
|
49
|
-
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
50
|
-
timelineElements: TimelineElement[];
|
|
51
|
-
getTimelineSelectionSet: () => ReadonlySet<string>;
|
|
52
|
-
setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
53
|
-
/** Publishes a whole multi-selection to the timeline; the anchor is set separately. */
|
|
54
|
-
setTimelineSelectionSet: (ids: Set<string>) => void;
|
|
55
|
-
setRightCollapsed: (collapsed: boolean) => void;
|
|
56
|
-
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
57
|
-
previewIframe: HTMLIFrameElement | null;
|
|
58
|
-
refreshKey: number;
|
|
59
|
-
rightPanelTab: RightPanelTab;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface UseDomSelectionReturn {
|
|
63
|
-
// State
|
|
64
|
-
domEditSelection: DomEditSelection | null;
|
|
65
|
-
domEditGroupSelections: DomEditSelection[];
|
|
66
|
-
domEditHoverSelection: DomEditSelection | null;
|
|
67
|
-
activeGroupElement: HTMLElement | null;
|
|
68
|
-
// Refs
|
|
69
|
-
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
70
|
-
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
|
71
|
-
domEditHoverSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
72
|
-
activeGroupElementRef: React.MutableRefObject<HTMLElement | null>;
|
|
73
|
-
// State setters (needed by useDomEditSession for agent-prompt reset flows)
|
|
74
|
-
setDomEditSelection: React.Dispatch<React.SetStateAction<DomEditSelection | null>>;
|
|
75
|
-
setDomEditGroupSelections: React.Dispatch<React.SetStateAction<DomEditSelection[]>>;
|
|
76
|
-
setActiveGroupElement: (el: HTMLElement | null) => void;
|
|
77
|
-
// Callbacks
|
|
78
|
-
applyDomSelection: (
|
|
79
|
-
selection: DomEditSelection | null,
|
|
80
|
-
options?: ApplyDomSelectionOptions,
|
|
81
|
-
) => void;
|
|
82
|
-
clearDomSelection: () => void;
|
|
83
|
-
buildDomSelectionFromTarget: (
|
|
84
|
-
target: HTMLElement,
|
|
85
|
-
options?: ResolveDomSelectionOptions,
|
|
86
|
-
) => Promise<DomEditSelection | null>;
|
|
87
|
-
resolveDomSelectionFromPreviewPoint: (
|
|
88
|
-
clientX: number,
|
|
89
|
-
clientY: number,
|
|
90
|
-
options?: ResolveDomSelectionOptions,
|
|
91
|
-
) => Promise<DomEditSelection | null>;
|
|
92
|
-
resolveAllDomSelectionsFromPreviewPoint: (
|
|
93
|
-
clientX: number,
|
|
94
|
-
clientY: number,
|
|
95
|
-
) => Promise<DomEditSelection[]>;
|
|
96
|
-
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
|
97
|
-
buildDomSelectionForTimelineElement: (
|
|
98
|
-
element: TimelineElement,
|
|
99
|
-
) => Promise<DomEditSelection | null>;
|
|
100
|
-
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
|
|
101
|
-
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
|
102
|
-
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
|
|
103
|
-
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
104
|
-
}
|
|
24
|
+
import type {
|
|
25
|
+
ApplyDomSelectionOptions,
|
|
26
|
+
UseDomSelectionParams,
|
|
27
|
+
UseDomSelectionReturn,
|
|
28
|
+
} from "./useDomSelectionTypes";
|
|
29
|
+
|
|
30
|
+
export type {
|
|
31
|
+
ApplyDomSelectionOptions,
|
|
32
|
+
ResolveDomSelectionOptions,
|
|
33
|
+
UseDomSelectionParams,
|
|
34
|
+
UseDomSelectionReturn,
|
|
35
|
+
} from "./useDomSelectionTypes";
|
|
105
36
|
|
|
106
37
|
// ── Hook ──
|
|
107
38
|
|
|
@@ -187,6 +118,19 @@ export function useDomSelection({
|
|
|
187
118
|
const isAdditiveSelection = Boolean(options?.additive);
|
|
188
119
|
const currentSelection = domEditSelectionRef.current;
|
|
189
120
|
const previousGroup = domEditGroupSelectionsRef.current;
|
|
121
|
+
const isRepeatedSingleSelection =
|
|
122
|
+
!isAdditiveSelection &&
|
|
123
|
+
!options?.preserveGroup &&
|
|
124
|
+
previousGroup.length === 1 &&
|
|
125
|
+
domEditSelectionsTargetSame(currentSelection, selection) &&
|
|
126
|
+
domEditSelectionsTargetSame(previousGroup[0], selection);
|
|
127
|
+
if (isRepeatedSingleSelection) {
|
|
128
|
+
if (options?.revealPanel !== false) {
|
|
129
|
+
setRightCollapsed(false);
|
|
130
|
+
if (rightPanelTabRef.current !== "variables") setRightPanelTab("design");
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
190
134
|
const currentGroup = isAdditiveSelection
|
|
191
135
|
? seedDomEditGroupWithSelection(previousGroup, currentSelection)
|
|
192
136
|
: previousGroup;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
import type { SelectElementOptions, TimelineElement } from "../player";
|
|
4
|
+
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
5
|
+
|
|
6
|
+
export interface ApplyDomSelectionOptions {
|
|
7
|
+
revealPanel?: boolean;
|
|
8
|
+
additive?: boolean;
|
|
9
|
+
preserveGroup?: boolean;
|
|
10
|
+
// A clear that came FROM the timeline must not be echoed back, or picking a
|
|
11
|
+
// clip with no canvas node would deselect the clip you just picked.
|
|
12
|
+
announce?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ResolveDomSelectionOptions {
|
|
16
|
+
preferClipAncestor?: boolean;
|
|
17
|
+
skipSourceProbe?: boolean;
|
|
18
|
+
activeGroupElement?: HTMLElement | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseDomSelectionParams {
|
|
22
|
+
projectId: string | null;
|
|
23
|
+
activeCompPath: string | null;
|
|
24
|
+
isMasterView: boolean;
|
|
25
|
+
compIdToSrc: Map<string, string>;
|
|
26
|
+
captionEditMode: boolean;
|
|
27
|
+
previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
|
|
28
|
+
timelineElements: TimelineElement[];
|
|
29
|
+
getTimelineSelectionSet: () => ReadonlySet<string>;
|
|
30
|
+
setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
31
|
+
/** Publishes a whole multi-selection to the timeline; the anchor is set separately. */
|
|
32
|
+
setTimelineSelectionSet: (ids: Set<string>) => void;
|
|
33
|
+
setRightCollapsed: (collapsed: boolean) => void;
|
|
34
|
+
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
35
|
+
previewIframe: HTMLIFrameElement | null;
|
|
36
|
+
refreshKey: number;
|
|
37
|
+
rightPanelTab: RightPanelTab;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UseDomSelectionReturn {
|
|
41
|
+
// State
|
|
42
|
+
domEditSelection: DomEditSelection | null;
|
|
43
|
+
domEditGroupSelections: DomEditSelection[];
|
|
44
|
+
domEditHoverSelection: DomEditSelection | null;
|
|
45
|
+
activeGroupElement: HTMLElement | null;
|
|
46
|
+
// Refs
|
|
47
|
+
domEditSelectionRef: MutableRefObject<DomEditSelection | null>;
|
|
48
|
+
domEditGroupSelectionsRef: MutableRefObject<DomEditSelection[]>;
|
|
49
|
+
domEditHoverSelectionRef: MutableRefObject<DomEditSelection | null>;
|
|
50
|
+
activeGroupElementRef: MutableRefObject<HTMLElement | null>;
|
|
51
|
+
// State setters (needed by useDomEditSession for agent-prompt reset flows)
|
|
52
|
+
setDomEditSelection: Dispatch<SetStateAction<DomEditSelection | null>>;
|
|
53
|
+
setDomEditGroupSelections: Dispatch<SetStateAction<DomEditSelection[]>>;
|
|
54
|
+
setActiveGroupElement: (el: HTMLElement | null) => void;
|
|
55
|
+
// Callbacks
|
|
56
|
+
applyDomSelection: (
|
|
57
|
+
selection: DomEditSelection | null,
|
|
58
|
+
options?: ApplyDomSelectionOptions,
|
|
59
|
+
) => void;
|
|
60
|
+
clearDomSelection: () => void;
|
|
61
|
+
buildDomSelectionFromTarget: (
|
|
62
|
+
target: HTMLElement,
|
|
63
|
+
options?: ResolveDomSelectionOptions,
|
|
64
|
+
) => Promise<DomEditSelection | null>;
|
|
65
|
+
resolveDomSelectionFromPreviewPoint: (
|
|
66
|
+
clientX: number,
|
|
67
|
+
clientY: number,
|
|
68
|
+
options?: ResolveDomSelectionOptions,
|
|
69
|
+
) => Promise<DomEditSelection | null>;
|
|
70
|
+
resolveAllDomSelectionsFromPreviewPoint: (
|
|
71
|
+
clientX: number,
|
|
72
|
+
clientY: number,
|
|
73
|
+
) => Promise<DomEditSelection[]>;
|
|
74
|
+
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
|
75
|
+
buildDomSelectionForTimelineElement: (
|
|
76
|
+
element: TimelineElement,
|
|
77
|
+
) => Promise<DomEditSelection | null>;
|
|
78
|
+
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
|
|
79
|
+
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
|
80
|
+
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
|
|
81
|
+
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
82
|
+
}
|
|
@@ -4,11 +4,15 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
7
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
7
8
|
import { usePanelLayout } from "./usePanelLayout";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
12
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
13
|
+
|
|
11
14
|
beforeEach(() => {
|
|
15
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
12
16
|
const entries = new Map<string, string>();
|
|
13
17
|
Object.defineProperty(window, "localStorage", {
|
|
14
18
|
configurable: true,
|
|
@@ -165,6 +169,29 @@ describe("usePanelLayout — right inspector panes", () => {
|
|
|
165
169
|
harness.unmount();
|
|
166
170
|
});
|
|
167
171
|
|
|
172
|
+
it("tracks only actual right-panel tab changes, including rapid repeated calls", () => {
|
|
173
|
+
const harness = renderPanelLayout();
|
|
174
|
+
|
|
175
|
+
act(() => {
|
|
176
|
+
harness.getState().setRightPanelTab("design");
|
|
177
|
+
harness.getState().setRightPanelTab("design");
|
|
178
|
+
});
|
|
179
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
180
|
+
|
|
181
|
+
act(() => {
|
|
182
|
+
harness.getState().setRightPanelTab("layers");
|
|
183
|
+
harness.getState().setRightPanelTab("layers");
|
|
184
|
+
});
|
|
185
|
+
expect(trackStudioEvent).toHaveBeenCalledOnce();
|
|
186
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("tab_switch", {
|
|
187
|
+
panel: "right_panel",
|
|
188
|
+
tab: "layers",
|
|
189
|
+
});
|
|
190
|
+
expect(harness.getState().rightPanelTab).toBe("layers");
|
|
191
|
+
|
|
192
|
+
harness.unmount();
|
|
193
|
+
});
|
|
194
|
+
|
|
168
195
|
it("caps a panel relative to the window instead of at a flat 600px", () => {
|
|
169
196
|
resizeWindowTo(700);
|
|
170
197
|
const harness = renderPanelLayout();
|
|
@@ -289,6 +316,17 @@ describe("usePanelLayout — right inspector panes", () => {
|
|
|
289
316
|
const harness = renderPanelLayoutWith(usePanelLayoutFlatOn);
|
|
290
317
|
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
291
318
|
|
|
319
|
+
// The flat inspector can change its visible pane without changing the
|
|
320
|
+
// umbrella rightPanelTab value. A later element selection must still bring
|
|
321
|
+
// Design back even though rightPanelTab already says "design".
|
|
322
|
+
act(() => harness.getState().setExclusiveRightInspectorPane("layers"));
|
|
323
|
+
expect(harness.getState()).toMatchObject({
|
|
324
|
+
rightPanelTab: "design",
|
|
325
|
+
rightInspectorPanes: { layers: true, design: false },
|
|
326
|
+
});
|
|
327
|
+
act(() => harness.getState().setRightPanelTab("design"));
|
|
328
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
329
|
+
|
|
292
330
|
// Element-select / block-params-close / header Inspector-button callers
|
|
293
331
|
// all reach setRightPanelTab directly, not through the in-panel tab
|
|
294
332
|
// click's own setExclusiveRightInspectorPane call — this must still
|
|
@@ -56,9 +56,13 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
56
56
|
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
|
|
57
57
|
initialState?.rightPanelTab ?? "design",
|
|
58
58
|
);
|
|
59
|
+
const rightPanelTabRef = useRef(rightPanelTab);
|
|
60
|
+
rightPanelTabRef.current = rightPanelTab;
|
|
59
61
|
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
|
|
60
62
|
getInitialRightInspectorPanes(initialState?.rightPanelTab),
|
|
61
63
|
);
|
|
64
|
+
const rightInspectorPanesRef = useRef(rightInspectorPanes);
|
|
65
|
+
rightInspectorPanesRef.current = rightInspectorPanes;
|
|
62
66
|
// Set when the user explicitly reopens a panel the window had auto-collapsed,
|
|
63
67
|
// so the rail cannot immediately swallow it again. Cleared once the window is
|
|
64
68
|
// wide enough that auto-collapse is no longer in play.
|
|
@@ -161,7 +165,9 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
161
165
|
|
|
162
166
|
const setRightCollapsedWithOverride = useCallback((collapsed: boolean) => {
|
|
163
167
|
setRightCollapsed(collapsed);
|
|
164
|
-
if (!collapsed)
|
|
168
|
+
if (!collapsed) {
|
|
169
|
+
setAutoCollapseOverride((prev) => (prev.right ? prev : { ...prev, right: true }));
|
|
170
|
+
}
|
|
165
171
|
}, []);
|
|
166
172
|
|
|
167
173
|
const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
|
|
@@ -192,6 +198,15 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
192
198
|
|
|
193
199
|
const trackedSetRightPanelTab = useCallback(
|
|
194
200
|
(tab: RightPanelTab) => {
|
|
201
|
+
const paneAlreadySelected =
|
|
202
|
+
tab !== "design" && tab !== "layers"
|
|
203
|
+
? true
|
|
204
|
+
: STUDIO_FLAT_INSPECTOR_ENABLED
|
|
205
|
+
? rightInspectorPanesRef.current[tab] &&
|
|
206
|
+
!rightInspectorPanesRef.current[tab === "design" ? "layers" : "design"]
|
|
207
|
+
: rightInspectorPanesRef.current[tab];
|
|
208
|
+
if (rightPanelTabRef.current === tab && paneAlreadySelected) return;
|
|
209
|
+
rightPanelTabRef.current = tab;
|
|
195
210
|
if (tab === "design" || tab === "layers") {
|
|
196
211
|
// Flat inspector: Layers always renders full-height by itself (see
|
|
197
212
|
// StudioRightPanel's render gate), so this MUST land on the same
|
|
@@ -202,11 +217,11 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
202
217
|
// inspector tab) would otherwise additively leave both panes `true`
|
|
203
218
|
// and reproduce the "both tabs highlight, only one renders" bug this
|
|
204
219
|
// still-additive branch used to cause under the flat flag.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
);
|
|
220
|
+
const nextPanes = STUDIO_FLAT_INSPECTOR_ENABLED
|
|
221
|
+
? { design: tab === "design", layers: tab === "layers" }
|
|
222
|
+
: { ...rightInspectorPanesRef.current, [tab]: true };
|
|
223
|
+
rightInspectorPanesRef.current = nextPanes;
|
|
224
|
+
setRightInspectorPanes(nextPanes);
|
|
210
225
|
}
|
|
211
226
|
setRightPanelTab(tab);
|
|
212
227
|
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* from here without creating circular dependencies.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { RuntimeTimelineClipIdentity } from "@hyperframes/core";
|
|
8
|
+
|
|
7
9
|
export interface PlaybackAdapter {
|
|
8
10
|
play: () => void;
|
|
9
11
|
pause: () => void;
|
|
@@ -32,23 +34,12 @@ export interface TimelineLike {
|
|
|
32
34
|
isActive: () => boolean;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
export interface ClipManifestClip {
|
|
36
|
-
id: string | null;
|
|
37
|
-
label: string;
|
|
38
|
-
start: number;
|
|
39
|
-
duration: number;
|
|
40
|
-
track: number;
|
|
37
|
+
export interface ClipManifestClip extends RuntimeTimelineClipIdentity {
|
|
41
38
|
zIndex?: number;
|
|
42
39
|
stackingContextId?: string | null;
|
|
43
|
-
kind: "video" | "audio" | "image" | "element" | "composition";
|
|
44
|
-
tagName: string | null;
|
|
45
|
-
compositionId: string | null;
|
|
46
40
|
compositionAncestors?: string[];
|
|
47
|
-
parentCompositionId: string | null;
|
|
48
|
-
compositionSrc: string | null;
|
|
49
41
|
playbackStart?: number;
|
|
50
42
|
playbackRate?: number;
|
|
51
|
-
assetUrl: string | null;
|
|
52
43
|
}
|
|
53
44
|
|
|
54
45
|
export interface ClipManifest {
|