@hyperframes/studio 0.8.47 → 0.8.48
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-B7fsB8C2.js → hyperframes-player-CtVX6jL_.js} +1 -1
- package/dist/assets/{index-ryL7q1QG.js → index-B3-orIjW.js} +1 -1
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/index-C9gJKpqQ.js +441 -0
- package/dist/assets/{index-B50xQfSG.js → index-D_j-0PD3.js} +1 -1
- package/dist/index.d.ts +66 -23
- package/dist/index.html +2 -2
- package/dist/index.js +1015 -621
- 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/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 +72 -0
- package/src/hooks/useTimelineAssetDropOps.ts +20 -16
- 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/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/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- 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/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/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/sdkCutover.test.ts +51 -0
- 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/timelineInspector.ts +10 -0
- package/dist/assets/index-DnRfAiK2.css +0 -1
- package/dist/assets/index-j-4hCrZU.js +0 -441
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
3
3
|
import { isAudioDomElement } from "../utils/timelineInspector";
|
|
4
|
-
import type {
|
|
4
|
+
import type { TimelineElement } from "../player";
|
|
5
5
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
6
6
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
7
7
|
import type { PatchTarget } from "../utils/sourcePatcher";
|
|
@@ -21,25 +21,15 @@ import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
|
21
21
|
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
|
22
22
|
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
|
|
23
23
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
24
|
-
import { membersForDelete } from "./domEditDeleteMembers";
|
|
24
|
+
import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
|
|
25
25
|
import type { RecordEditInput } from "./domEditDeleteMembers";
|
|
26
|
+
import type { DomEditTimelineParams } from "./useDomSelectionTypes";
|
|
26
27
|
// Re-exported: the delete rule lives in its own module now, and callers (and its
|
|
27
28
|
// own test) have always imported it from here.
|
|
28
29
|
export { membersForDelete };
|
|
29
30
|
|
|
30
|
-
export interface UseDomEditSessionParams {
|
|
31
|
-
projectId: string | null;
|
|
32
|
-
activeCompPath: string | null;
|
|
33
|
-
compIdToSrc: Map<string, string>;
|
|
34
|
-
captionEditMode: boolean;
|
|
31
|
+
export interface UseDomEditSessionParams extends DomEditTimelineParams {
|
|
35
32
|
compositionLoading: boolean;
|
|
36
|
-
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
37
|
-
timelineElements: TimelineElement[];
|
|
38
|
-
getTimelineSelectionSet: () => ReadonlySet<string>;
|
|
39
|
-
setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
40
|
-
setTimelineSelectionSet: (ids: Set<string>) => void;
|
|
41
|
-
setRightCollapsed: (collapsed: boolean) => void;
|
|
42
|
-
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
43
33
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
44
34
|
isRecordingRef?: React.RefObject<boolean>;
|
|
45
35
|
refreshPreviewDocumentVersion: () => void;
|
|
@@ -68,6 +58,9 @@ export interface UseDomEditSessionParams {
|
|
|
68
58
|
sdkSession?: Composition | null;
|
|
69
59
|
publishSdkSession?: PublishSdkSession;
|
|
70
60
|
forceReloadSdkSession?: () => void;
|
|
61
|
+
/** The timeline context menu's delete op — a canvas selection that IS a
|
|
62
|
+
* timeline row hands off here instead of the REST remove-elements path. */
|
|
63
|
+
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
71
64
|
}
|
|
72
65
|
|
|
73
66
|
export function useDomEditSession({
|
|
@@ -109,6 +102,7 @@ export function useDomEditSession({
|
|
|
109
102
|
sdkSession,
|
|
110
103
|
publishSdkSession,
|
|
111
104
|
forceReloadSdkSession,
|
|
105
|
+
handleTimelineElementsDelete,
|
|
112
106
|
}: UseDomEditSessionParams) {
|
|
113
107
|
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
114
108
|
void _setRefreshKey;
|
|
@@ -260,11 +254,9 @@ export function useDomEditSession({
|
|
|
260
254
|
forceReloadSdkSession,
|
|
261
255
|
onTrySdkPersist: sdkSession
|
|
262
256
|
? (selection, operations, originalContent, targetPath, options) => {
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
// the paths so cross-file edits (session models only the active comp)
|
|
267
|
-
// skip instead of emitting structural element_not_found noise.
|
|
257
|
+
// Decoupled tripwire, runs regardless of the cutover flag. originalContent lets
|
|
258
|
+
// the runtime-node filter suppress hf-ids absent from source (script-created
|
|
259
|
+
// nodes); the paths let a cross-file edit skip instead of a false not-found.
|
|
268
260
|
runResolverShadow(sdkSession, selection.hfId, operations, originalContent, {
|
|
269
261
|
targetPath,
|
|
270
262
|
compositionPath: activeCompPath,
|
|
@@ -298,9 +290,8 @@ export function useDomEditSession({
|
|
|
298
290
|
publishSession: publishSdkSession,
|
|
299
291
|
})
|
|
300
292
|
: undefined,
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
// the SDK resolves each reordered element (the reorderElements op's targets).
|
|
293
|
+
// Z-index reorder takes the server path (no SDK persist); this decoupled
|
|
294
|
+
// tripwire still records whether the SDK resolves each reordered target.
|
|
304
295
|
onReorderShadow: sdkSession
|
|
305
296
|
? (targets: string[]) => {
|
|
306
297
|
// Single-flight: every target in one reorder batch shares the same file, so
|
|
@@ -326,21 +317,33 @@ export function useDomEditSession({
|
|
|
326
317
|
reloadPreview,
|
|
327
318
|
clearDomSelection,
|
|
328
319
|
forceReloadSdkSession,
|
|
320
|
+
timelineElements,
|
|
329
321
|
});
|
|
330
322
|
|
|
331
323
|
const handleDomEditElementDelete = useCallback(
|
|
332
324
|
async (selection: DomEditSelection, options?: { expandGroup?: boolean }) => {
|
|
333
|
-
// Same structural edit the timeline delete refuses mid-recording
|
|
334
|
-
// refuses here too — this is now the path a Delete press takes whenever
|
|
335
|
-
// the canvas holds a selection.
|
|
325
|
+
// Same structural edit the timeline delete refuses mid-recording.
|
|
336
326
|
if (isRecordingRef?.current) {
|
|
337
327
|
showToast("Cannot edit timeline while recording", "error");
|
|
338
328
|
return;
|
|
339
329
|
}
|
|
340
330
|
const members = membersForDelete(selection, domEditGroupSelectionsRef.current, options);
|
|
331
|
+
// A selection that is itself timeline rows shares the context menu's delete op.
|
|
332
|
+
const timelineTargets = timelineElementsForDelete(members, timelineElements);
|
|
333
|
+
if (timelineTargets) {
|
|
334
|
+
await handleTimelineElementsDelete(timelineTargets);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
341
337
|
await handleDomEditElementsDelete(members);
|
|
342
338
|
},
|
|
343
|
-
[
|
|
339
|
+
[
|
|
340
|
+
domEditGroupSelectionsRef,
|
|
341
|
+
handleDomEditElementsDelete,
|
|
342
|
+
handleTimelineElementsDelete,
|
|
343
|
+
isRecordingRef,
|
|
344
|
+
showToast,
|
|
345
|
+
timelineElements,
|
|
346
|
+
],
|
|
344
347
|
);
|
|
345
348
|
|
|
346
349
|
const handleGroupSelection = useCallback(() => {
|
|
@@ -351,14 +354,8 @@ export function useDomEditSession({
|
|
|
351
354
|
showToast("Select at least 2 elements to group", "info");
|
|
352
355
|
return;
|
|
353
356
|
}
|
|
354
|
-
// A layout group
|
|
355
|
-
//
|
|
356
|
-
// z-index. An <audio> clip has no box — offsetWidth/Height are 0 — so
|
|
357
|
-
// grouping audio produced a 0x0 div with inline left/top written onto
|
|
358
|
-
// elements that have never been laid out, and the timeline gained a
|
|
359
|
-
// wrapper standing for nothing audible. The audio answer to "these clips
|
|
360
|
-
// belong together" is an <hf-audio-group> bus, which the timeline's own FX
|
|
361
|
-
// pointer creates, so the refusal names it rather than just declining.
|
|
357
|
+
// A layout group takes the members' bounding box; audio has no box (0x0),
|
|
358
|
+
// so it groups into an <hf-audio-group> bus via the track's FX pointer instead.
|
|
362
359
|
if (members.some((m) => isAudioDomElement(m.element))) {
|
|
363
360
|
showToast(
|
|
364
361
|
members.every((m) => isAudioDomElement(m.element))
|
|
@@ -20,10 +20,11 @@ export interface ResolveDomSelectionOptions {
|
|
|
20
20
|
exactTarget?: boolean;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Shared by every hook that needs the project/timeline/panel context a DOM
|
|
24
|
+
// edit session is built around — useDomEditSession has its own superset.
|
|
25
|
+
export interface DomEditTimelineParams {
|
|
24
26
|
projectId: string | null;
|
|
25
27
|
activeCompPath: string | null;
|
|
26
|
-
isMasterView: boolean;
|
|
27
28
|
compIdToSrc: Map<string, string>;
|
|
28
29
|
captionEditMode: boolean;
|
|
29
30
|
previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
|
|
@@ -34,6 +35,10 @@ export interface UseDomSelectionParams {
|
|
|
34
35
|
setTimelineSelectionSet: (ids: Set<string>) => void;
|
|
35
36
|
setRightCollapsed: (collapsed: boolean) => void;
|
|
36
37
|
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UseDomSelectionParams extends DomEditTimelineParams {
|
|
41
|
+
isMasterView: boolean;
|
|
37
42
|
previewIframe: HTMLIFrameElement | null;
|
|
38
43
|
refreshKey: number;
|
|
39
44
|
rightPanelTab: RightPanelTab;
|
|
@@ -389,15 +389,7 @@ export function useElementLifecycleOps({
|
|
|
389
389
|
[commitDomEditPatchBatches, onReorderShadow],
|
|
390
390
|
);
|
|
391
391
|
|
|
392
|
-
const handleDomEditElementDelete = useCallback(
|
|
393
|
-
async (selection: DomEditSelection) => {
|
|
394
|
-
await handleDomEditElementsDelete([selection]);
|
|
395
|
-
},
|
|
396
|
-
[handleDomEditElementsDelete],
|
|
397
|
-
);
|
|
398
|
-
|
|
399
392
|
return {
|
|
400
|
-
handleDomEditElementDelete,
|
|
401
393
|
handleDomEditElementsDelete,
|
|
402
394
|
handleDomZIndexReorderCommit,
|
|
403
395
|
};
|
|
@@ -67,7 +67,6 @@ export function useFileManager({
|
|
|
67
67
|
const {
|
|
68
68
|
projectDir,
|
|
69
69
|
fileTree,
|
|
70
|
-
setFileTree,
|
|
71
70
|
fileTreeLoaded,
|
|
72
71
|
refreshFileTree,
|
|
73
72
|
compositions,
|
|
@@ -501,7 +500,6 @@ export function useFileManager({
|
|
|
501
500
|
projectDir,
|
|
502
501
|
fileTree,
|
|
503
502
|
fileTreeLoaded,
|
|
504
|
-
setFileTree,
|
|
505
503
|
|
|
506
504
|
// Refs
|
|
507
505
|
editingPathRef,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, useImperativeHandle, useRef, forwardRef } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useFileTree } from "./useFileTree";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
let root: Root | null = null;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (root) act(() => root?.unmount());
|
|
14
|
+
root = null;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
interface Handle {
|
|
18
|
+
compositions: string[];
|
|
19
|
+
refresh: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const Harness = forwardRef<Handle, { projectId: string }>(function Harness({ projectId }, ref) {
|
|
23
|
+
const projectIdRef = useRef(projectId);
|
|
24
|
+
projectIdRef.current = projectId;
|
|
25
|
+
const { compositions, refreshFileTree } = useFileTree({ projectId, projectIdRef });
|
|
26
|
+
useImperativeHandle(ref, () => ({ compositions, refresh: refreshFileTree }), [
|
|
27
|
+
compositions,
|
|
28
|
+
refreshFileTree,
|
|
29
|
+
]);
|
|
30
|
+
return null;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function renderHarness(projectId: string): Promise<{ current: Handle | null }> {
|
|
34
|
+
const handleRef = { current: null as Handle | null };
|
|
35
|
+
const host = document.createElement("div");
|
|
36
|
+
document.body.append(host);
|
|
37
|
+
root = createRoot(host);
|
|
38
|
+
|
|
39
|
+
await act(async () => {
|
|
40
|
+
root?.render(
|
|
41
|
+
<Harness
|
|
42
|
+
ref={(h) => {
|
|
43
|
+
handleRef.current = h;
|
|
44
|
+
}}
|
|
45
|
+
projectId={projectId}
|
|
46
|
+
/>,
|
|
47
|
+
);
|
|
48
|
+
await Promise.resolve();
|
|
49
|
+
await Promise.resolve();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return handleRef;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("useFileTree.refreshFileTree", () => {
|
|
56
|
+
it("updates compositions, not just the raw file list, on refresh", async () => {
|
|
57
|
+
let call = 0;
|
|
58
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
|
59
|
+
call += 1;
|
|
60
|
+
// Initial load: one composition. After a refresh (e.g. a new file created),
|
|
61
|
+
// the server now reports a second one — this is the exact shape a refresh
|
|
62
|
+
// after creating/duplicating a composition produces.
|
|
63
|
+
const body =
|
|
64
|
+
call === 1
|
|
65
|
+
? { files: ["index.html"], compositions: ["index.html"] }
|
|
66
|
+
: { files: ["index.html", "hero.html"], compositions: ["index.html", "hero.html"] };
|
|
67
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const handleRef = await renderHarness("project-a");
|
|
71
|
+
expect(handleRef.current?.compositions).toEqual(["index.html"]);
|
|
72
|
+
|
|
73
|
+
await act(async () => {
|
|
74
|
+
await handleRef.current?.refresh();
|
|
75
|
+
});
|
|
76
|
+
expect(handleRef.current?.compositions).toEqual(["index.html", "hero.html"]);
|
|
77
|
+
|
|
78
|
+
fetchSpy.mockRestore();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps the most-recently-issued refresh's data when an earlier one resolves later", async () => {
|
|
82
|
+
let call = 0;
|
|
83
|
+
const deferred: Array<(value: Response) => void> = [];
|
|
84
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
|
85
|
+
call += 1;
|
|
86
|
+
if (call === 1) {
|
|
87
|
+
return new Response(
|
|
88
|
+
JSON.stringify({ files: ["index.html"], compositions: ["index.html"] }),
|
|
89
|
+
{
|
|
90
|
+
status: 200,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return new Promise<Response>((resolve) => {
|
|
95
|
+
deferred.push(resolve);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const handleRef = await renderHarness("project-a");
|
|
100
|
+
|
|
101
|
+
let firstDone = false;
|
|
102
|
+
let secondDone = false;
|
|
103
|
+
act(() => {
|
|
104
|
+
void handleRef.current?.refresh().then(() => {
|
|
105
|
+
firstDone = true;
|
|
106
|
+
});
|
|
107
|
+
void handleRef.current?.refresh().then(() => {
|
|
108
|
+
secondDone = true;
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The second (later-issued) request resolves first, with its own data.
|
|
113
|
+
await act(async () => {
|
|
114
|
+
deferred[1](
|
|
115
|
+
new Response(
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
files: ["index.html", "second.html"],
|
|
118
|
+
compositions: ["index.html", "second.html"],
|
|
119
|
+
}),
|
|
120
|
+
{
|
|
121
|
+
status: 200,
|
|
122
|
+
},
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
await vi.waitFor(() => secondDone);
|
|
126
|
+
});
|
|
127
|
+
expect(handleRef.current?.compositions).toEqual(["index.html", "second.html"]);
|
|
128
|
+
|
|
129
|
+
// The first (earlier-issued, now stale) request resolves after — it must not win.
|
|
130
|
+
await act(async () => {
|
|
131
|
+
deferred[0](
|
|
132
|
+
new Response(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
files: ["index.html", "stale.html"],
|
|
135
|
+
compositions: ["index.html", "stale.html"],
|
|
136
|
+
}),
|
|
137
|
+
{
|
|
138
|
+
status: 200,
|
|
139
|
+
},
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
await vi.waitFor(() => firstDone);
|
|
143
|
+
});
|
|
144
|
+
expect(handleRef.current?.compositions).toEqual(["index.html", "second.html"]);
|
|
145
|
+
|
|
146
|
+
fetchSpy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("aborts the superseded request's fetch when a newer refresh is issued", async () => {
|
|
150
|
+
let call = 0;
|
|
151
|
+
const signals: AbortSignal[] = [];
|
|
152
|
+
const fetchSpy = vi
|
|
153
|
+
.spyOn(globalThis, "fetch")
|
|
154
|
+
.mockImplementation(async (_url, init?: RequestInit) => {
|
|
155
|
+
call += 1;
|
|
156
|
+
if (call === 1) {
|
|
157
|
+
return new Response(
|
|
158
|
+
JSON.stringify({ files: ["index.html"], compositions: ["index.html"] }),
|
|
159
|
+
{ status: 200 },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (init?.signal) signals.push(init.signal);
|
|
163
|
+
return new Promise<Response>(() => {});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const handleRef = await renderHarness("project-a");
|
|
167
|
+
|
|
168
|
+
act(() => {
|
|
169
|
+
void handleRef.current?.refresh();
|
|
170
|
+
});
|
|
171
|
+
await act(async () => {
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
});
|
|
174
|
+
expect(signals).toHaveLength(1);
|
|
175
|
+
expect(signals[0].aborted).toBe(false);
|
|
176
|
+
|
|
177
|
+
act(() => {
|
|
178
|
+
void handleRef.current?.refresh();
|
|
179
|
+
});
|
|
180
|
+
await act(async () => {
|
|
181
|
+
await Promise.resolve();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(signals).toHaveLength(2);
|
|
185
|
+
expect(signals[0].aborted).toBe(true);
|
|
186
|
+
expect(signals[1].aborted).toBe(false);
|
|
187
|
+
|
|
188
|
+
fetchSpy.mockRestore();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("keeps the prior compositions list when a refresh response omits the field", async () => {
|
|
192
|
+
let call = 0;
|
|
193
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
|
194
|
+
call += 1;
|
|
195
|
+
const body =
|
|
196
|
+
call === 1
|
|
197
|
+
? { files: ["index.html"], compositions: ["index.html"] }
|
|
198
|
+
: { files: ["index.html", "hero.html"] };
|
|
199
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const handleRef = await renderHarness("project-a");
|
|
203
|
+
expect(handleRef.current?.compositions).toEqual(["index.html"]);
|
|
204
|
+
|
|
205
|
+
await act(async () => {
|
|
206
|
+
await handleRef.current?.refresh();
|
|
207
|
+
});
|
|
208
|
+
// `files` grew but the response carried no `compositions` field — the known-good
|
|
209
|
+
// list must survive, not collapse to empty.
|
|
210
|
+
expect(handleRef.current?.compositions).toEqual(["index.html"]);
|
|
211
|
+
|
|
212
|
+
fetchSpy.mockRestore();
|
|
213
|
+
});
|
|
214
|
+
});
|
package/src/hooks/useFileTree.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
|
-
import { useState, useCallback, useEffect, useMemo } from "react";
|
|
2
|
+
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
|
3
3
|
import { FONT_EXT } from "../utils/mediaTypes";
|
|
4
4
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
5
|
import { captureProjectProvenance } from "../components/feedback/projectProvenance";
|
|
@@ -9,52 +9,95 @@ interface UseFileTreeOptions {
|
|
|
9
9
|
projectIdRef: React.RefObject<string | null>;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
interface FetchedFileTree {
|
|
13
|
+
projectId: string;
|
|
14
|
+
loaded: boolean;
|
|
15
|
+
fileTree: string[];
|
|
16
|
+
compositionPaths: string[];
|
|
17
|
+
projectDir: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Stable references so a mismatched projectId doesn't create a new array every render
|
|
21
|
+
// and defeat useMemo/useCallback deps downstream (e.g. the `assets` memo below).
|
|
22
|
+
const EMPTY_FILE_LIST: string[] = [];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Holds the tree with the `projectId` it was fetched for; exposes empty/not-loaded
|
|
26
|
+
* whenever that id doesn't match the current one, so a switch can't read another project's tree.
|
|
27
|
+
*/
|
|
12
28
|
export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
|
|
13
|
-
const [
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
29
|
+
const [fetched, setFetched] = useState<FetchedFileTree | null>(null);
|
|
30
|
+
const current = fetched?.projectId === projectId ? fetched : null;
|
|
31
|
+
const fileTree = current?.fileTree ?? EMPTY_FILE_LIST;
|
|
32
|
+
const compositionPaths = current?.compositionPaths ?? EMPTY_FILE_LIST;
|
|
33
|
+
const projectDir = current?.projectDir ?? null;
|
|
34
|
+
const fileTreeLoaded = current?.loaded ?? false;
|
|
17
35
|
|
|
18
36
|
// eslint-disable-next-line no-restricted-syntax
|
|
19
37
|
useEffect(() => {
|
|
20
|
-
if (!projectId)
|
|
21
|
-
setFileTreeLoaded(false);
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
38
|
+
if (!projectId) return;
|
|
24
39
|
let cancelled = false;
|
|
25
|
-
|
|
40
|
+
setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
|
|
26
41
|
fetch(buildProjectApiPath(projectId))
|
|
27
42
|
.then((r) => r.json())
|
|
28
43
|
.then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
|
|
29
44
|
if (cancelled) return;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
setFetched({
|
|
46
|
+
projectId,
|
|
47
|
+
loaded: true,
|
|
48
|
+
fileTree: data.files ?? [],
|
|
49
|
+
compositionPaths: data.compositions ?? [],
|
|
50
|
+
projectDir: typeof data.dir === "string" ? data.dir : null,
|
|
51
|
+
});
|
|
33
52
|
// Snapshot how this project was made, while the listing is in hand and
|
|
34
53
|
// the app is still alive. A crash later has no other way to learn it.
|
|
35
54
|
void captureProjectProvenance(projectId, data.files ?? [], data.compositions ?? []);
|
|
36
55
|
})
|
|
37
56
|
.catch(() => {
|
|
38
|
-
if (!cancelled)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!cancelled) setFileTreeLoaded(true);
|
|
57
|
+
if (!cancelled) {
|
|
58
|
+
setFetched((prev) => (prev?.projectId === projectId ? { ...prev, loaded: true } : prev));
|
|
59
|
+
}
|
|
42
60
|
});
|
|
43
61
|
return () => {
|
|
44
62
|
cancelled = true;
|
|
45
63
|
};
|
|
46
64
|
}, [projectId]);
|
|
47
65
|
|
|
66
|
+
const refreshRequestRef = useRef(0);
|
|
67
|
+
const refreshAbortRef = useRef<AbortController | null>(null);
|
|
68
|
+
|
|
48
69
|
const refreshFileTree = useCallback(async () => {
|
|
49
70
|
const pid = projectIdRef.current;
|
|
50
71
|
if (!pid) return;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
// Same id+abort pair as useFileManager.ts's openSourceForSelection: cancel the
|
|
73
|
+
// superseded request instead of letting it complete and discarding the result.
|
|
74
|
+
refreshAbortRef.current?.abort();
|
|
75
|
+
const requestId = ++refreshRequestRef.current;
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
refreshAbortRef.current = controller;
|
|
78
|
+
let data: { files?: string[]; compositions?: string[] };
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(buildProjectApiPath(pid), { signal: controller.signal });
|
|
81
|
+
data = await res.json();
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
if (data.files && requestId === refreshRequestRef.current) {
|
|
87
|
+
setFetched((prev) =>
|
|
88
|
+
prev?.projectId === pid
|
|
89
|
+
? {
|
|
90
|
+
...prev,
|
|
91
|
+
fileTree: data.files ?? prev.fileTree,
|
|
92
|
+
// A response that omits `compositions` must not wipe out a
|
|
93
|
+
// known-good list — only replace it when the field is present.
|
|
94
|
+
compositionPaths: data.compositions ?? prev.compositionPaths,
|
|
95
|
+
}
|
|
96
|
+
: prev,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
54
99
|
}, [projectIdRef]);
|
|
55
100
|
|
|
56
|
-
const compositions = compositionPaths;
|
|
57
|
-
|
|
58
101
|
const assets = useMemo(
|
|
59
102
|
() =>
|
|
60
103
|
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
|
@@ -76,10 +119,9 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
|
|
|
76
119
|
return {
|
|
77
120
|
projectDir,
|
|
78
121
|
fileTree,
|
|
79
|
-
setFileTree,
|
|
80
122
|
fileTreeLoaded,
|
|
81
123
|
refreshFileTree,
|
|
82
|
-
compositions,
|
|
124
|
+
compositions: compositionPaths,
|
|
83
125
|
assets,
|
|
84
126
|
fontAssets,
|
|
85
127
|
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { TimelineElement } from "../player";
|
|
4
|
+
import { computeGroupGeometry, resolveGroupChildTracks } from "./useGroupCommits";
|
|
5
|
+
import { makeSelection } from "./domSelectionTestHarness";
|
|
6
|
+
|
|
7
|
+
const timelineElement = (over: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
8
|
+
id: "clip",
|
|
9
|
+
domId: "clip",
|
|
10
|
+
tag: "div",
|
|
11
|
+
start: 0,
|
|
12
|
+
duration: 4,
|
|
13
|
+
track: 0,
|
|
14
|
+
timingSource: "authored",
|
|
15
|
+
sourceFile: "index.html",
|
|
16
|
+
...over,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("computeGroupGeometry — track threading", () => {
|
|
20
|
+
it("carries each member's authored track onto its rebase entry, so the server can pin it through the wrap", () => {
|
|
21
|
+
const elA = document.createElement("div");
|
|
22
|
+
elA.id = "clip-a";
|
|
23
|
+
const elB = document.createElement("div");
|
|
24
|
+
elB.id = "clip-b";
|
|
25
|
+
const members = [makeSelection("A", elA), makeSelection("B", elB)];
|
|
26
|
+
const timelineElements = [
|
|
27
|
+
timelineElement({ id: "clip-a", domId: "clip-a", authoredTrack: 2 }),
|
|
28
|
+
timelineElement({ id: "clip-b", domId: "clip-b" }),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const { rebases } = computeGroupGeometry(members, timelineElements);
|
|
32
|
+
|
|
33
|
+
expect(rebases[0]?.track).toBe(2);
|
|
34
|
+
expect(rebases[1]?.track).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("resolveGroupChildTracks", () => {
|
|
39
|
+
it("resolves each child's authored track by DOM id, so ungroup can pin it through the unwrap", () => {
|
|
40
|
+
const group = document.createElement("div");
|
|
41
|
+
group.id = "group-1";
|
|
42
|
+
const childA = document.createElement("div");
|
|
43
|
+
childA.id = "child-a";
|
|
44
|
+
const childB = document.createElement("div");
|
|
45
|
+
childB.id = "child-b";
|
|
46
|
+
group.append(childA, childB);
|
|
47
|
+
|
|
48
|
+
const timelineElements = [
|
|
49
|
+
timelineElement({ id: "child-a", domId: "child-a", authoredTrack: 1 }),
|
|
50
|
+
timelineElement({ id: "child-b", domId: "child-b" }),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const result = resolveGroupChildTracks(makeSelection("Group 1", group), timelineElements);
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual([
|
|
56
|
+
{ target: { id: "child-a", hfId: undefined }, track: 1 },
|
|
57
|
+
{ target: { id: "child-b", hfId: undefined }, track: 0 },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("resolves a child's track by hfId when it has no DOM id, so an unauthored ungroup child can't drift to a new row", () => {
|
|
62
|
+
const group = document.createElement("div");
|
|
63
|
+
group.id = "group-1";
|
|
64
|
+
const child = document.createElement("div");
|
|
65
|
+
child.setAttribute("data-hf-id", "hf-child-a");
|
|
66
|
+
group.append(child);
|
|
67
|
+
|
|
68
|
+
const timelineElements = [timelineElement({ hfId: "hf-child-a", authoredTrack: 3 })];
|
|
69
|
+
|
|
70
|
+
const result = resolveGroupChildTracks(makeSelection("Group 1", group), timelineElements);
|
|
71
|
+
|
|
72
|
+
expect(result).toEqual([{ target: { id: undefined, hfId: "hf-child-a" }, track: 3 }]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("skips a child with neither a DOM id nor an hfId", () => {
|
|
76
|
+
const group = document.createElement("div");
|
|
77
|
+
const anonymous = document.createElement("div");
|
|
78
|
+
group.append(anonymous);
|
|
79
|
+
|
|
80
|
+
const result = resolveGroupChildTracks(makeSelection("Group 1", group), []);
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("falls back to the runtime's resolved track for an implicit child with no authoredTrack", () => {
|
|
86
|
+
const group = document.createElement("div");
|
|
87
|
+
group.id = "group-1";
|
|
88
|
+
const child = document.createElement("div");
|
|
89
|
+
child.id = "child-a";
|
|
90
|
+
group.append(child);
|
|
91
|
+
|
|
92
|
+
const timelineElements = [timelineElement({ id: "child-a", domId: "child-a", track: 3 })];
|
|
93
|
+
|
|
94
|
+
const result = resolveGroupChildTracks(makeSelection("Group 1", group), timelineElements);
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual([{ target: { id: "child-a", hfId: undefined }, track: 3 }]);
|
|
97
|
+
});
|
|
98
|
+
});
|