@hyperframes/studio 0.8.47 → 0.8.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
- package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +70 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1452 -918
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
- package/src/hooks/useTimelineAssetDropOps.ts +24 -18
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +18 -49
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +8 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
- package/dist/assets/index-DnRfAiK2.css +0 -1
|
@@ -5,13 +5,24 @@ import {
|
|
|
5
5
|
saveProjectFilesWithHistory,
|
|
6
6
|
type DomEditCommitBaseParams,
|
|
7
7
|
} from "../utils/studioFileHistory";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildDomEditPatchTarget,
|
|
10
|
+
readHfId,
|
|
11
|
+
type DomEditSelection,
|
|
12
|
+
} from "../components/editor/domEditing";
|
|
9
13
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
14
|
+
import {
|
|
15
|
+
findMatchingTimelineElementId,
|
|
16
|
+
resolveElementTrack,
|
|
17
|
+
type ElementMatchSelection,
|
|
18
|
+
} from "../utils/studioHelpers";
|
|
19
|
+
import type { TimelineElement } from "../player";
|
|
10
20
|
|
|
11
21
|
interface UseGroupCommitsParams extends DomEditCommitBaseParams {
|
|
12
22
|
/** Resync the SDK session after a server-side write (the wrapper/unwrap changes
|
|
13
23
|
* structure the in-memory doc doesn't know about). */
|
|
14
24
|
forceReloadSdkSession?: () => void;
|
|
25
|
+
timelineElements: TimelineElement[];
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
interface PatchTarget {
|
|
@@ -24,7 +35,21 @@ interface PatchTarget {
|
|
|
24
35
|
interface GroupGeometry {
|
|
25
36
|
bbox: { left: number; top: number; width: number; height: number };
|
|
26
37
|
targets: PatchTarget[];
|
|
27
|
-
rebases: Array<{ target: PatchTarget; left: number; top: number }>;
|
|
38
|
+
rebases: Array<{ target: PatchTarget; left: number; top: number; track?: number }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The member's current resolved track (authored, or the runtime's positional-
|
|
42
|
+
// index fallback). Threaded through so the server can stamp it explicitly —
|
|
43
|
+
// same hazard and fix as the razor split.
|
|
44
|
+
function resolveAuthoredTrack(
|
|
45
|
+
selection: ElementMatchSelection,
|
|
46
|
+
timelineElements: TimelineElement[],
|
|
47
|
+
): number | undefined {
|
|
48
|
+
const id = findMatchingTimelineElementId(selection, timelineElements);
|
|
49
|
+
if (!id) return undefined;
|
|
50
|
+
const match = timelineElements.find((el) => (el.key ?? el.id) === id);
|
|
51
|
+
if (!match) return undefined;
|
|
52
|
+
return resolveElementTrack(match);
|
|
28
53
|
}
|
|
29
54
|
|
|
30
55
|
// Wrapper sits at the members' bounding box top-left; each member is rebased so
|
|
@@ -32,13 +57,17 @@ interface GroupGeometry {
|
|
|
32
57
|
// composition space (transforms excluded), exactly the space the rebase formula
|
|
33
58
|
// `left_new = left_old - W.left` operates in — GSAP x/y and offset vars are
|
|
34
59
|
// transform deltas and stay correct without adjustment.
|
|
35
|
-
function computeGroupGeometry(
|
|
60
|
+
export function computeGroupGeometry(
|
|
61
|
+
members: DomEditSelection[],
|
|
62
|
+
timelineElements: TimelineElement[],
|
|
63
|
+
): GroupGeometry {
|
|
36
64
|
const boxes = members.map((m) => ({
|
|
37
65
|
target: buildDomEditPatchTarget(m),
|
|
38
66
|
left: m.element.offsetLeft,
|
|
39
67
|
top: m.element.offsetTop,
|
|
40
68
|
right: m.element.offsetLeft + m.element.offsetWidth,
|
|
41
69
|
bottom: m.element.offsetTop + m.element.offsetHeight,
|
|
70
|
+
track: resolveAuthoredTrack(m, timelineElements),
|
|
42
71
|
}));
|
|
43
72
|
const left = Math.min(...boxes.map((b) => b.left));
|
|
44
73
|
const top = Math.min(...boxes.map((b) => b.top));
|
|
@@ -47,10 +76,37 @@ function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry {
|
|
|
47
76
|
return {
|
|
48
77
|
bbox: { left, top, width, height },
|
|
49
78
|
targets: boxes.map((b) => b.target),
|
|
50
|
-
rebases: boxes.map((b) => ({
|
|
79
|
+
rebases: boxes.map((b) => ({
|
|
80
|
+
target: b.target,
|
|
81
|
+
left: b.left - left,
|
|
82
|
+
top: b.top - top,
|
|
83
|
+
track: b.track,
|
|
84
|
+
})),
|
|
51
85
|
};
|
|
52
86
|
}
|
|
53
87
|
|
|
88
|
+
// Ungroup re-derives each child's track from the raw DOM (no DomEditSelection
|
|
89
|
+
// exists per child); matches by id, falling back to hfId for a child with no
|
|
90
|
+
// authored id. A child with neither keeps its current track, unstamped.
|
|
91
|
+
export function resolveGroupChildTracks(
|
|
92
|
+
group: DomEditSelection,
|
|
93
|
+
timelineElements: TimelineElement[],
|
|
94
|
+
): Array<{ target: PatchTarget; track?: number }> {
|
|
95
|
+
const sourceFile = group.sourceFile || "index.html";
|
|
96
|
+
const result: Array<{ target: PatchTarget; track?: number }> = [];
|
|
97
|
+
for (const child of Array.from(group.element.children)) {
|
|
98
|
+
const id = (child as HTMLElement).id || undefined;
|
|
99
|
+
const hfId = readHfId(child);
|
|
100
|
+
if (!id && !hfId) continue;
|
|
101
|
+
const track = resolveAuthoredTrack(
|
|
102
|
+
{ id, hfId, sourceFile, isCompositionHost: false },
|
|
103
|
+
timelineElements,
|
|
104
|
+
);
|
|
105
|
+
result.push({ target: buildDomEditPatchTarget({ id, hfId }), track });
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
54
110
|
// Shared read → mutate-route → save-with-history → reload pipeline for both
|
|
55
111
|
// wrap (group) and unwrap (ungroup). Mirrors the structural-mutation pattern in
|
|
56
112
|
// useElementLifecycleOps (delete). Returns the route's JSON, or throws.
|
|
@@ -103,7 +159,7 @@ async function commitStructuralMutation(
|
|
|
103
159
|
}
|
|
104
160
|
|
|
105
161
|
export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
106
|
-
const { activeCompPath, showToast, projectIdRef } = params;
|
|
162
|
+
const { activeCompPath, showToast, projectIdRef, timelineElements } = params;
|
|
107
163
|
|
|
108
164
|
const groupSelection = useCallback(
|
|
109
165
|
async (members: DomEditSelection[]): Promise<string | null> => {
|
|
@@ -121,7 +177,7 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
|
121
177
|
// Auto-name "Group N" by the count of existing groups in the document.
|
|
122
178
|
const doc = members[0].element.ownerDocument;
|
|
123
179
|
const groupId = `Group ${doc.querySelectorAll("[data-hf-group]").length + 1}`;
|
|
124
|
-
const { bbox, targets, rebases } = computeGroupGeometry(members);
|
|
180
|
+
const { bbox, targets, rebases } = computeGroupGeometry(members, timelineElements);
|
|
125
181
|
|
|
126
182
|
try {
|
|
127
183
|
const data = await commitStructuralMutation(
|
|
@@ -138,7 +194,7 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
|
138
194
|
return null;
|
|
139
195
|
}
|
|
140
196
|
},
|
|
141
|
-
[activeCompPath, projectIdRef, showToast, params],
|
|
197
|
+
[activeCompPath, projectIdRef, showToast, params, timelineElements],
|
|
142
198
|
);
|
|
143
199
|
|
|
144
200
|
const ungroupSelection = useCallback(
|
|
@@ -146,13 +202,14 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
|
146
202
|
const pid = projectIdRef.current;
|
|
147
203
|
if (!pid) return;
|
|
148
204
|
const targetPath = group.sourceFile || activeCompPath || "index.html";
|
|
205
|
+
const childTracks = resolveGroupChildTracks(group, timelineElements);
|
|
149
206
|
|
|
150
207
|
try {
|
|
151
208
|
await commitStructuralMutation(
|
|
152
209
|
pid,
|
|
153
210
|
targetPath,
|
|
154
211
|
"unwrap-elements",
|
|
155
|
-
{ target: buildDomEditPatchTarget(group) },
|
|
212
|
+
{ target: buildDomEditPatchTarget(group), childTracks },
|
|
156
213
|
"Ungroup elements",
|
|
157
214
|
params,
|
|
158
215
|
);
|
|
@@ -160,7 +217,7 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
|
160
217
|
showToast(error instanceof Error ? error.message : "Failed to ungroup elements", "error");
|
|
161
218
|
}
|
|
162
219
|
},
|
|
163
|
-
[activeCompPath, projectIdRef, showToast, params],
|
|
220
|
+
[activeCompPath, projectIdRef, showToast, params, timelineElements],
|
|
164
221
|
);
|
|
165
222
|
|
|
166
223
|
return { groupSelection, ungroupSelection };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useResetSelectionOnProjectSwitch } from "./useResetSelectionOnProjectSwitch";
|
|
7
|
+
import { useAutoOpenRootComposition } from "./useAutoOpenRootComposition";
|
|
8
|
+
import { useActiveComposition } from "./useActiveComposition";
|
|
9
|
+
import { useFileTree } from "./useFileTree";
|
|
10
|
+
import {
|
|
11
|
+
isHydratedFromUrlState,
|
|
12
|
+
readStudioUrlStateFromWindow,
|
|
13
|
+
resolveMasterCompositionPath,
|
|
14
|
+
} from "../utils/studioUrlState";
|
|
15
|
+
|
|
16
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
|
+
|
|
18
|
+
let root: Root | null = null;
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (root) act(() => root?.unmount());
|
|
22
|
+
root = null;
|
|
23
|
+
document.body.innerHTML = "";
|
|
24
|
+
window.location.hash = "";
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("useResetSelectionOnProjectSwitch", () => {
|
|
28
|
+
function ResetHarness({ projectId }: { projectId: string | null }) {
|
|
29
|
+
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
30
|
+
const [activeCompPath, setActiveCompPath] = useState<string | null>("stale-comp.html");
|
|
31
|
+
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(true);
|
|
32
|
+
useResetSelectionOnProjectSwitch({
|
|
33
|
+
projectId,
|
|
34
|
+
initialUrlStateRef,
|
|
35
|
+
setActiveCompPath,
|
|
36
|
+
setActiveCompPathHydrated,
|
|
37
|
+
});
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
data-active-comp-path={activeCompPath ?? ""}
|
|
41
|
+
data-hydrated={String(activeCompPathHydrated)}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it("does not reset on the initial resolution from null", () => {
|
|
47
|
+
const host = document.createElement("div");
|
|
48
|
+
document.body.append(host);
|
|
49
|
+
root = createRoot(host);
|
|
50
|
+
act(() => {
|
|
51
|
+
root?.render(<ResetHarness projectId={null} />);
|
|
52
|
+
});
|
|
53
|
+
act(() => {
|
|
54
|
+
root?.render(<ResetHarness projectId="project-a" />);
|
|
55
|
+
});
|
|
56
|
+
const el = host.firstElementChild as HTMLElement;
|
|
57
|
+
expect(el.dataset.activeCompPath).toBe("stale-comp.html");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("resets activeCompPath and re-derives hydrated on an actual project switch", () => {
|
|
61
|
+
const host = document.createElement("div");
|
|
62
|
+
document.body.append(host);
|
|
63
|
+
root = createRoot(host);
|
|
64
|
+
act(() => {
|
|
65
|
+
root?.render(<ResetHarness projectId="project-a" />);
|
|
66
|
+
});
|
|
67
|
+
act(() => {
|
|
68
|
+
root?.render(<ResetHarness projectId="project-b" />);
|
|
69
|
+
});
|
|
70
|
+
const el = host.firstElementChild as HTMLElement;
|
|
71
|
+
expect(el.dataset.activeCompPath).toBe("");
|
|
72
|
+
expect(el.dataset.hydrated).toBe("true");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("project switch integration (mirrors App.tsx's wiring)", () => {
|
|
77
|
+
function AppLikeHarness({
|
|
78
|
+
projectId,
|
|
79
|
+
masterCompPath,
|
|
80
|
+
onSelectComposition,
|
|
81
|
+
}: {
|
|
82
|
+
projectId: string | null;
|
|
83
|
+
masterCompPath: string | null;
|
|
84
|
+
onSelectComposition: (comp: string) => void;
|
|
85
|
+
}) {
|
|
86
|
+
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
87
|
+
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
88
|
+
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(() =>
|
|
89
|
+
isHydratedFromUrlState(initialUrlStateRef.current),
|
|
90
|
+
);
|
|
91
|
+
// Mirrors handleSelectComposition (useCompositionContentLoader): selecting a composition
|
|
92
|
+
// also updates activeCompPath, which is exactly the state that must be reset on switch.
|
|
93
|
+
const handleSelect = (comp: string) => {
|
|
94
|
+
setActiveCompPath(comp);
|
|
95
|
+
onSelectComposition(comp);
|
|
96
|
+
};
|
|
97
|
+
// Mirrors useActiveComposition's wiring: reset as a sibling call, before auto-open.
|
|
98
|
+
useResetSelectionOnProjectSwitch({
|
|
99
|
+
projectId,
|
|
100
|
+
initialUrlStateRef,
|
|
101
|
+
setActiveCompPath,
|
|
102
|
+
setActiveCompPathHydrated,
|
|
103
|
+
});
|
|
104
|
+
useAutoOpenRootComposition({
|
|
105
|
+
projectId,
|
|
106
|
+
activeCompPath,
|
|
107
|
+
activeCompPathHydrated,
|
|
108
|
+
masterCompPath,
|
|
109
|
+
onSelectComposition: handleSelect,
|
|
110
|
+
});
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it("opens the new project's root after switching projects with a composition already open", () => {
|
|
115
|
+
const onSelectComposition = vi.fn();
|
|
116
|
+
const host = document.createElement("div");
|
|
117
|
+
document.body.append(host);
|
|
118
|
+
root = createRoot(host);
|
|
119
|
+
|
|
120
|
+
// Project A resolves and auto-opens its root, which also sets activeCompPath — the
|
|
121
|
+
// "something already open" state a real user would be in before switching projects.
|
|
122
|
+
act(() => {
|
|
123
|
+
root?.render(
|
|
124
|
+
<AppLikeHarness
|
|
125
|
+
projectId="project-a"
|
|
126
|
+
masterCompPath="index.html"
|
|
127
|
+
onSelectComposition={onSelectComposition}
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
|
|
132
|
+
onSelectComposition.mockClear();
|
|
133
|
+
|
|
134
|
+
// Switch to project B in-session (hash change, no remount) — this is the exact scenario
|
|
135
|
+
// the adversarial review found broken: activeCompPath was never reset, so project B's
|
|
136
|
+
// auto-open silently no-op'd because activeCompPath still read as "already set".
|
|
137
|
+
act(() => {
|
|
138
|
+
root?.render(
|
|
139
|
+
<AppLikeHarness
|
|
140
|
+
projectId="project-b"
|
|
141
|
+
masterCompPath="hero.html"
|
|
142
|
+
onSelectComposition={onSelectComposition}
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("hero.html");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("full stack: useFileTree -> useActiveComposition (masterCompPath staleness)", () => {
|
|
152
|
+
const PROJECT_DATA: Record<string, { files: string[]; compositions: string[] }> = {
|
|
153
|
+
"project-a": { files: ["index.html"], compositions: ["index.html"] },
|
|
154
|
+
"project-b": { files: ["hero.html"], compositions: ["hero.html"] },
|
|
155
|
+
// A preset/vendor .html can be in `files` without being a real composition —
|
|
156
|
+
// this is the exact shape a review found auto-open mis-targeting.
|
|
157
|
+
"project-c": { files: ["preset.html", "hero.html"], compositions: ["hero.html"] },
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function mockFetch(url: string): Promise<Response> {
|
|
161
|
+
const match = /^\/api\/projects\/([^/]+)$/.exec(url);
|
|
162
|
+
if (match) {
|
|
163
|
+
const data = PROJECT_DATA[decodeURIComponent(match[1])] ?? { files: [], compositions: [] };
|
|
164
|
+
return Promise.resolve(new Response(JSON.stringify(data), { status: 200 }));
|
|
165
|
+
}
|
|
166
|
+
return Promise.resolve(new Response(null, { status: 404 }));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function FullStackHarness({ projectId }: { projectId: string | null }) {
|
|
170
|
+
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
171
|
+
const projectIdRef = useRef(projectId);
|
|
172
|
+
projectIdRef.current = projectId;
|
|
173
|
+
const { fileTree, fileTreeLoaded, compositions } = useFileTree({ projectId, projectIdRef });
|
|
174
|
+
const masterCompPath = useMemo(
|
|
175
|
+
() => resolveMasterCompositionPath(compositions),
|
|
176
|
+
[compositions],
|
|
177
|
+
);
|
|
178
|
+
const { activeCompPath } = useActiveComposition({
|
|
179
|
+
projectId,
|
|
180
|
+
initialUrlStateRef,
|
|
181
|
+
fileTree,
|
|
182
|
+
fileTreeLoaded,
|
|
183
|
+
masterCompPath,
|
|
184
|
+
setEditingFile: vi.fn(),
|
|
185
|
+
showToast: vi.fn(),
|
|
186
|
+
});
|
|
187
|
+
return <div data-active-comp-path={activeCompPath ?? ""} />;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it("opens project B's real root, not project A's stale master path, on an in-session switch", async () => {
|
|
191
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as typeof fetch);
|
|
192
|
+
const host = document.createElement("div");
|
|
193
|
+
document.body.append(host);
|
|
194
|
+
root = createRoot(host);
|
|
195
|
+
|
|
196
|
+
await act(async () => {
|
|
197
|
+
root?.render(<FullStackHarness projectId="project-a" />);
|
|
198
|
+
await Promise.resolve();
|
|
199
|
+
await Promise.resolve();
|
|
200
|
+
});
|
|
201
|
+
let el = host.firstElementChild as HTMLElement;
|
|
202
|
+
expect(el.dataset.activeCompPath).toBe("index.html");
|
|
203
|
+
|
|
204
|
+
// Switch in-session (no remount) before asserting — this is the exact race the
|
|
205
|
+
// adversarial review found: project A's fileTree/masterCompPath are still in state
|
|
206
|
+
// for at least one render after projectId flips, until B's own fetch resolves.
|
|
207
|
+
await act(async () => {
|
|
208
|
+
root?.render(<FullStackHarness projectId="project-b" />);
|
|
209
|
+
await Promise.resolve();
|
|
210
|
+
await Promise.resolve();
|
|
211
|
+
});
|
|
212
|
+
el = host.firstElementChild as HTMLElement;
|
|
213
|
+
expect(el.dataset.activeCompPath).toBe("hero.html");
|
|
214
|
+
|
|
215
|
+
fetchSpy.mockRestore();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("auto-opens the real composition, not a non-composition .html earlier in the file list", async () => {
|
|
219
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as typeof fetch);
|
|
220
|
+
const host = document.createElement("div");
|
|
221
|
+
document.body.append(host);
|
|
222
|
+
root = createRoot(host);
|
|
223
|
+
|
|
224
|
+
await act(async () => {
|
|
225
|
+
root?.render(<FullStackHarness projectId="project-c" />);
|
|
226
|
+
await Promise.resolve();
|
|
227
|
+
await Promise.resolve();
|
|
228
|
+
});
|
|
229
|
+
const el = host.firstElementChild as HTMLElement;
|
|
230
|
+
expect(el.dataset.activeCompPath).toBe("hero.html");
|
|
231
|
+
|
|
232
|
+
fetchSpy.mockRestore();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
|
2
|
+
import {
|
|
3
|
+
isHydratedFromUrlState,
|
|
4
|
+
readStudioUrlStateFromWindow,
|
|
5
|
+
type StudioUrlState,
|
|
6
|
+
} from "../utils/studioUrlState";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resets selection when `projectId` switches in-session. Runs during render, not an
|
|
10
|
+
* effect, so the caller never sees a stale activeCompPath in the same commit.
|
|
11
|
+
*/
|
|
12
|
+
export function useResetSelectionOnProjectSwitch({
|
|
13
|
+
projectId,
|
|
14
|
+
initialUrlStateRef,
|
|
15
|
+
setActiveCompPath,
|
|
16
|
+
setActiveCompPathHydrated,
|
|
17
|
+
}: {
|
|
18
|
+
projectId: string | null;
|
|
19
|
+
initialUrlStateRef: MutableRefObject<StudioUrlState>;
|
|
20
|
+
setActiveCompPath: Dispatch<SetStateAction<string | null>>;
|
|
21
|
+
setActiveCompPathHydrated: Dispatch<SetStateAction<boolean>>;
|
|
22
|
+
}): void {
|
|
23
|
+
const previousProjectIdRef = useRef(projectId);
|
|
24
|
+
|
|
25
|
+
if (previousProjectIdRef.current !== projectId) {
|
|
26
|
+
const isSwitch = previousProjectIdRef.current !== null;
|
|
27
|
+
previousProjectIdRef.current = projectId;
|
|
28
|
+
if (isSwitch) {
|
|
29
|
+
initialUrlStateRef.current = readStudioUrlStateFromWindow();
|
|
30
|
+
setActiveCompPath(null);
|
|
31
|
+
setActiveCompPathHydrated(isHydratedFromUrlState(initialUrlStateRef.current));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
6
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
vi.unstubAllGlobals();
|
|
14
|
+
usePlayerStore.getState().reset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDrop"];
|
|
18
|
+
|
|
19
|
+
function renderDropHook(
|
|
20
|
+
sourceContent: string,
|
|
21
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
22
|
+
) {
|
|
23
|
+
vi.stubGlobal(
|
|
24
|
+
"fetch",
|
|
25
|
+
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ content: sourceContent }) }),
|
|
26
|
+
);
|
|
27
|
+
let drop: DropFn | null = null;
|
|
28
|
+
function Harness() {
|
|
29
|
+
const { handleTimelineAssetDrop } = useTimelineAssetDropOps({
|
|
30
|
+
projectIdRef: { current: "project" },
|
|
31
|
+
activeCompPath: "index.html",
|
|
32
|
+
timelineElements: [],
|
|
33
|
+
showToast: vi.fn(),
|
|
34
|
+
writeProjectFile,
|
|
35
|
+
recordEdit: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
reloadPreview: vi.fn(),
|
|
37
|
+
uploadProjectFiles: vi.fn().mockResolvedValue([]),
|
|
38
|
+
});
|
|
39
|
+
drop = handleTimelineAssetDrop;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
mountReactHarness(<Harness />);
|
|
43
|
+
return () => drop!;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
47
|
+
it("grows the root duration when the drop lands past the current end", async () => {
|
|
48
|
+
const source =
|
|
49
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
|
|
50
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
51
|
+
const getDrop = renderDropHook(source, writeProjectFile);
|
|
52
|
+
|
|
53
|
+
await act(async () => {
|
|
54
|
+
await getDrop()("clip.mp4", { start: 8, track: 0 }, 5);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
58
|
+
expect(written).toContain('data-duration="13"');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("leaves the root duration alone when the drop lands inside the current end", async () => {
|
|
62
|
+
const source =
|
|
63
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
|
|
64
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
65
|
+
const getDrop = renderDropHook(source, writeProjectFile);
|
|
66
|
+
|
|
67
|
+
await act(async () => {
|
|
68
|
+
await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
72
|
+
expect(written).toContain('data-duration="10"');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("selects and reveals the newly dropped clip", async () => {
|
|
76
|
+
const source =
|
|
77
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
|
|
78
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
79
|
+
const getDrop = renderDropHook(source, writeProjectFile);
|
|
80
|
+
|
|
81
|
+
await act(async () => {
|
|
82
|
+
await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -20,7 +20,9 @@ import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelp
|
|
|
20
20
|
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
|
21
21
|
import { readFileContent } from "./timelineTimingSync";
|
|
22
22
|
import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
|
|
23
|
-
import {
|
|
23
|
+
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
24
|
+
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
25
|
+
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
24
26
|
|
|
25
27
|
interface UseTimelineAssetDropOpsOptions {
|
|
26
28
|
projectIdRef: MutableRefObject<string | null>;
|
|
@@ -89,22 +91,25 @@ export function useTimelineAssetDropOps({
|
|
|
89
91
|
);
|
|
90
92
|
const newElementZIndex = Math.max(1, relevantElements.length + 1);
|
|
91
93
|
|
|
92
|
-
const patchedContent =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
94
|
+
const patchedContent = extendRootDurationInSource(
|
|
95
|
+
insertTimelineAssetIntoSource(
|
|
96
|
+
originalContent,
|
|
97
|
+
buildTimelineAssetInsertHtml({
|
|
98
|
+
id: newId,
|
|
99
|
+
hfId: `hf-${generateId()}`,
|
|
100
|
+
assetPath: resolvedAssetSrc,
|
|
101
|
+
kind,
|
|
102
|
+
start: normalizedStart,
|
|
103
|
+
duration: normalizedDuration,
|
|
104
|
+
track: placement.track,
|
|
105
|
+
zIndex: newElementZIndex,
|
|
106
|
+
geometry: fitTimelineAssetGeometry(
|
|
107
|
+
null,
|
|
108
|
+
resolveTimelineAssetCompositionSize(originalContent),
|
|
109
|
+
),
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
normalizedStart + normalizedDuration,
|
|
108
113
|
);
|
|
109
114
|
|
|
110
115
|
await saveProjectFilesWithHistory({
|
|
@@ -117,6 +122,7 @@ export function useTimelineAssetDropOps({
|
|
|
117
122
|
recordEdit,
|
|
118
123
|
});
|
|
119
124
|
|
|
125
|
+
selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
|
|
120
126
|
forceReloadSdkSession?.();
|
|
121
127
|
reloadPreview();
|
|
122
128
|
} catch (error) {
|
|
@@ -190,7 +196,7 @@ export function useTimelineAssetDropOps({
|
|
|
190
196
|
writeFile: writeProjectFile,
|
|
191
197
|
recordEdit,
|
|
192
198
|
observeVersion: observeProjectFileVersion,
|
|
193
|
-
selectHost:
|
|
199
|
+
selectHost: selectAndRevealTimelineElement,
|
|
194
200
|
resync: forceReloadSdkSession,
|
|
195
201
|
refresh: reloadPreview,
|
|
196
202
|
});
|