@hyperframes/studio 0.7.59 → 0.7.60
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-Bm07FLkl.js → hyperframes-player-3XTTaVNf.js} +1 -1
- package/dist/assets/{index-B995FG46.js → index-D6etaey-.js} +1 -1
- package/dist/assets/index-DXbu6IPT.css +1 -0
- package/dist/assets/{index-FvzmPhfG.js → index-Dh_WhagG.js} +1 -1
- package/dist/assets/{index-CrkAdJkb.js → index-cH6NfVV_.js} +198 -198
- package/dist/index.html +2 -2
- package/dist/index.js +250 -108
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +1 -1
- package/src/components/StudioRightPanel.tsx +13 -2
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
- package/src/components/editor/propertyPanelFlatTextSection.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +2 -2
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
- package/src/hooks/useFileManager.ts +76 -12
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useTimelineEditing.ts +2 -0
- package/src/hooks/useTimelineEditingTypes.ts +2 -1
- package/src/hooks/useTimelineGroupEditing.ts +1 -1
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.test.ts +21 -5
- package/src/utils/sdkEditTransaction.ts +5 -4
- package/src/utils/studioFileHistory.ts +6 -4
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +21 -0
- package/dist/assets/index-Dj5p8U_A.css +0 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePanelLayout } from "./usePanelLayout";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
vi.doUnmock("../components/editor/manualEditingAvailability");
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function renderPanelLayoutWith(hook: typeof usePanelLayout) {
|
|
17
|
+
const host = document.createElement("div");
|
|
18
|
+
document.body.append(host);
|
|
19
|
+
const root = createRoot(host);
|
|
20
|
+
let current: ReturnType<typeof usePanelLayout> | null = null;
|
|
21
|
+
|
|
22
|
+
function Harness() {
|
|
23
|
+
current = hook();
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
act(() => {
|
|
28
|
+
root.render(React.createElement(Harness));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
getState: (): ReturnType<typeof usePanelLayout> => {
|
|
33
|
+
if (!current) throw new Error("usePanelLayout did not render");
|
|
34
|
+
return current;
|
|
35
|
+
},
|
|
36
|
+
unmount: () => act(() => root.unmount()),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderPanelLayout() {
|
|
41
|
+
return renderPanelLayoutWith(usePanelLayout);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("usePanelLayout — right inspector panes", () => {
|
|
45
|
+
it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => {
|
|
46
|
+
const harness = renderPanelLayout();
|
|
47
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
48
|
+
|
|
49
|
+
act(() => harness.getState().toggleRightInspectorPane("layers"));
|
|
50
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
|
|
51
|
+
|
|
52
|
+
harness.unmount();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("toggleRightInspectorPane refuses to turn off the last remaining pane", () => {
|
|
56
|
+
const harness = renderPanelLayout();
|
|
57
|
+
act(() => harness.getState().toggleRightInspectorPane("design"));
|
|
58
|
+
// Only "design" was on; toggling it off would leave both false — guarded.
|
|
59
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
60
|
+
harness.unmount();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("setExclusiveRightInspectorPane is radio-style — selecting one turns the other off", () => {
|
|
64
|
+
const harness = renderPanelLayout();
|
|
65
|
+
act(() => harness.getState().toggleRightInspectorPane("layers"));
|
|
66
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
|
|
67
|
+
|
|
68
|
+
act(() => harness.getState().setExclusiveRightInspectorPane("layers"));
|
|
69
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false });
|
|
70
|
+
|
|
71
|
+
act(() => harness.getState().setExclusiveRightInspectorPane("design"));
|
|
72
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
73
|
+
|
|
74
|
+
harness.unmount();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("setRightPanelTab additively opens a pane when the flat inspector is off (legacy behavior)", async () => {
|
|
78
|
+
vi.resetModules();
|
|
79
|
+
vi.doMock("../components/editor/manualEditingAvailability", async () => {
|
|
80
|
+
const actual = await vi.importActual<
|
|
81
|
+
typeof import("../components/editor/manualEditingAvailability")
|
|
82
|
+
>("../components/editor/manualEditingAvailability");
|
|
83
|
+
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
|
|
84
|
+
});
|
|
85
|
+
const { usePanelLayout: usePanelLayoutFlatOff } = await import("./usePanelLayout");
|
|
86
|
+
const harness = renderPanelLayoutWith(usePanelLayoutFlatOff);
|
|
87
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
88
|
+
|
|
89
|
+
act(() => harness.getState().setRightPanelTab("layers"));
|
|
90
|
+
// Legacy (split-view) behavior: additive, both panes end up open.
|
|
91
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
|
|
92
|
+
|
|
93
|
+
harness.unmount();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
|
|
97
|
+
vi.resetModules();
|
|
98
|
+
vi.doMock("../components/editor/manualEditingAvailability", async () => {
|
|
99
|
+
const actual = await vi.importActual<
|
|
100
|
+
typeof import("../components/editor/manualEditingAvailability")
|
|
101
|
+
>("../components/editor/manualEditingAvailability");
|
|
102
|
+
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
|
|
103
|
+
});
|
|
104
|
+
const { usePanelLayout: usePanelLayoutFlatOn } = await import("./usePanelLayout");
|
|
105
|
+
const harness = renderPanelLayoutWith(usePanelLayoutFlatOn);
|
|
106
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
107
|
+
|
|
108
|
+
// Element-select / block-params-close / header Inspector-button callers
|
|
109
|
+
// all reach setRightPanelTab directly, not through the in-panel tab
|
|
110
|
+
// click's own setExclusiveRightInspectorPane call — this must still
|
|
111
|
+
// enforce exclusivity under the flat flag, or both tabs end up
|
|
112
|
+
// highlighted while only one pane actually renders.
|
|
113
|
+
act(() => harness.getState().setRightPanelTab("layers"));
|
|
114
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false });
|
|
115
|
+
|
|
116
|
+
harness.unmount();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "../utils/studioHelpers";
|
|
7
7
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
8
8
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
9
|
+
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
9
10
|
|
|
10
11
|
export interface InitialPanelLayoutState {
|
|
11
12
|
rightCollapsed?: boolean | null;
|
|
@@ -80,7 +81,20 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
80
81
|
const trackedSetRightPanelTab = useCallback(
|
|
81
82
|
(tab: RightPanelTab) => {
|
|
82
83
|
if (tab === "design" || tab === "layers") {
|
|
83
|
-
|
|
84
|
+
// Flat inspector: Layers always renders full-height by itself (see
|
|
85
|
+
// StudioRightPanel's render gate), so this MUST land on the same
|
|
86
|
+
// radio-style exclusivity setExclusiveRightInspectorPane enforces for
|
|
87
|
+
// the direct in-panel tab click — every OTHER path that reaches here
|
|
88
|
+
// (element select, closing block-params, the header Inspector
|
|
89
|
+
// button, and this function's own callers outside an active
|
|
90
|
+
// inspector tab) would otherwise additively leave both panes `true`
|
|
91
|
+
// and reproduce the "both tabs highlight, only one renders" bug this
|
|
92
|
+
// still-additive branch used to cause under the flat flag.
|
|
93
|
+
setRightInspectorPanes(
|
|
94
|
+
STUDIO_FLAT_INSPECTOR_ENABLED
|
|
95
|
+
? { design: tab === "design", layers: tab === "layers" }
|
|
96
|
+
: (panes) => ({ ...panes, [tab]: true }),
|
|
97
|
+
);
|
|
84
98
|
}
|
|
85
99
|
setRightPanelTab(tab);
|
|
86
100
|
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
|
@@ -96,6 +110,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
96
110
|
});
|
|
97
111
|
}, []);
|
|
98
112
|
|
|
113
|
+
// Radio-style variant for the flat inspector: Layers always renders full-
|
|
114
|
+
// height by itself there (never split-shared with Design), so leaving both
|
|
115
|
+
// panes independently toggleable would highlight both tabs as "active"
|
|
116
|
+
// while only one actually shows. Selecting one turns the other off.
|
|
117
|
+
const setExclusiveRightInspectorPane = useCallback((pane: RightInspectorPane) => {
|
|
118
|
+
setRightInspectorPanes({ design: pane === "design", layers: pane === "layers" });
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
99
121
|
return {
|
|
100
122
|
leftWidth,
|
|
101
123
|
setLeftWidth,
|
|
@@ -109,6 +131,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
109
131
|
setRightPanelTab: trackedSetRightPanelTab,
|
|
110
132
|
rightInspectorPanes,
|
|
111
133
|
toggleRightInspectorPane,
|
|
134
|
+
setExclusiveRightInspectorPane,
|
|
112
135
|
toggleLeftSidebar,
|
|
113
136
|
handlePanelResizeStart,
|
|
114
137
|
handlePanelResizeMove,
|
|
@@ -133,7 +133,10 @@ export function usePreviewPersistence({
|
|
|
133
133
|
if (!domEditSaveQueueRef.current) {
|
|
134
134
|
domEditSaveQueueRef.current = createDomEditSaveQueue({
|
|
135
135
|
onOpen: (event) => {
|
|
136
|
-
const message =
|
|
136
|
+
const message =
|
|
137
|
+
event.statusCode === 409
|
|
138
|
+
? "Save paused: this file changed elsewhere. Reload and review the latest version before reapplying your edit."
|
|
139
|
+
: "Auto-save is paused. Check your connection.";
|
|
137
140
|
setDomEditSaveQueuePaused(message);
|
|
138
141
|
showToastRef.current(message, "error");
|
|
139
142
|
trackStudioEvent("save_queue_paused", {
|
|
@@ -76,9 +76,10 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
76
76
|
// Mirror the server: rewrites the GSAP script for the new id, writes to
|
|
77
77
|
// disk, returns the final content.
|
|
78
78
|
disk["index.html"] = SPLIT_GSAP;
|
|
79
|
-
|
|
79
|
+
const version = `"test-gsap-${SPLIT_GSAP.length}"`;
|
|
80
|
+
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP, version }), {
|
|
80
81
|
status: 200,
|
|
81
|
-
headers: { "Content-Type": "application/json" },
|
|
82
|
+
headers: { "Content-Type": "application/json", ETag: version },
|
|
82
83
|
});
|
|
83
84
|
}
|
|
84
85
|
// The fixture has no GSAP script — mirror the server's 400 response.
|
|
@@ -89,9 +90,16 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
89
90
|
}
|
|
90
91
|
if (u.includes("/file-mutations/split-element/")) {
|
|
91
92
|
disk["index.html"] = SPLIT;
|
|
93
|
+
const version = `"test-split-${SPLIT.length}"`;
|
|
92
94
|
return new Response(
|
|
93
|
-
JSON.stringify({
|
|
94
|
-
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
ok: true,
|
|
97
|
+
changed: true,
|
|
98
|
+
content: SPLIT,
|
|
99
|
+
newId: "clip1-split",
|
|
100
|
+
version,
|
|
101
|
+
}),
|
|
102
|
+
{ status: 200, headers: { "Content-Type": "application/json", ETag: version } },
|
|
95
103
|
);
|
|
96
104
|
}
|
|
97
105
|
if (u.includes("/files/")) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../player";
|
|
6
|
+
import { useRazorSplit } from "./useRazorSplit";
|
|
7
|
+
|
|
8
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
11
|
+
return new Response(JSON.stringify(body), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("useRazorSplit mutation versions", () => {
|
|
18
|
+
afterEach(() => vi.restoreAllMocks());
|
|
19
|
+
|
|
20
|
+
it("observes each out-of-band mutation version before the OCC writer runs", async () => {
|
|
21
|
+
const original = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
|
|
22
|
+
const htmlSplit =
|
|
23
|
+
'<div id="clip" data-start="0" data-duration="2">Clip</div><div id="clip-split" data-start="2" data-duration="2">Clip</div>';
|
|
24
|
+
const final = `${htmlSplit}<script>window.__timelines = {}</script>`;
|
|
25
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
26
|
+
const url = String(input);
|
|
27
|
+
if (url.includes("/files/")) return jsonResponse({ content: original });
|
|
28
|
+
if (url.includes("/file-mutations/split-element/")) {
|
|
29
|
+
return jsonResponse({ ok: true, changed: true, content: htmlSplit, version: '"v-html"' });
|
|
30
|
+
}
|
|
31
|
+
if (url.includes("/gsap-mutations/")) {
|
|
32
|
+
return jsonResponse({ ok: true, changed: true, after: final, version: '"v-gsap"' });
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Unexpected request: ${url}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const order: string[] = [];
|
|
38
|
+
const observeProjectFileVersion = vi.fn((path: string, version: string | null) => {
|
|
39
|
+
order.push(`observe:${path}:${version}`);
|
|
40
|
+
});
|
|
41
|
+
const writeProjectFile = vi.fn(async () => {
|
|
42
|
+
order.push("write");
|
|
43
|
+
});
|
|
44
|
+
const recordEdit = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
let split: ((element: TimelineElement, splitTime: number) => Promise<void>) | undefined;
|
|
46
|
+
const container = document.createElement("div");
|
|
47
|
+
document.body.appendChild(container);
|
|
48
|
+
const root = createRoot(container);
|
|
49
|
+
|
|
50
|
+
function Harness() {
|
|
51
|
+
split = useRazorSplit({
|
|
52
|
+
projectId: "p1",
|
|
53
|
+
activeCompPath: "index.html",
|
|
54
|
+
showToast: vi.fn(),
|
|
55
|
+
writeProjectFile,
|
|
56
|
+
observeProjectFileVersion,
|
|
57
|
+
recordEdit,
|
|
58
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
59
|
+
reloadPreview: vi.fn(),
|
|
60
|
+
}).handleRazorSplit;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await act(async () => root.render(React.createElement(Harness)));
|
|
65
|
+
await act(async () => {
|
|
66
|
+
await split?.(
|
|
67
|
+
{
|
|
68
|
+
id: "clip",
|
|
69
|
+
domId: "clip",
|
|
70
|
+
hfId: "clip",
|
|
71
|
+
tag: "div",
|
|
72
|
+
start: 0,
|
|
73
|
+
duration: 4,
|
|
74
|
+
track: 0,
|
|
75
|
+
timingSource: "authored",
|
|
76
|
+
},
|
|
77
|
+
2,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(order).toEqual(['observe:index.html:"v-html"', 'observe:index.html:"v-gsap"', "write"]);
|
|
82
|
+
expect(writeProjectFile).toHaveBeenCalledWith("index.html", final, original);
|
|
83
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
84
|
+
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
container.remove();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -36,10 +36,11 @@ export function createSplitFetchMock(
|
|
|
36
36
|
onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
|
|
37
37
|
// Return content that differs from the original so `changed` is true.
|
|
38
38
|
const after = `${disk[path]}<!--split-->`;
|
|
39
|
+
const version = `"test-${path}-${after.length}"`;
|
|
39
40
|
disk[path] = after; // server writes the split to disk
|
|
40
|
-
return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
|
|
41
|
+
return new Response(JSON.stringify({ ok: true, changed: true, content: after, version }), {
|
|
41
42
|
status: 200,
|
|
42
|
-
headers: { "Content-Type": "application/json" },
|
|
43
|
+
headers: { "Content-Type": "application/json", ETag: version },
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
if (u.includes("/files/")) {
|
|
@@ -16,7 +16,8 @@ interface UseRazorSplitOptions {
|
|
|
16
16
|
projectId: string | null;
|
|
17
17
|
activeCompPath: string | null;
|
|
18
18
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
19
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
19
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
20
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
20
21
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
21
22
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
22
23
|
reloadPreview: () => void;
|
|
@@ -48,7 +49,7 @@ async function splitHtmlElement(
|
|
|
48
49
|
newId: string,
|
|
49
50
|
elementStart: number,
|
|
50
51
|
elementDuration: number,
|
|
51
|
-
): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
|
|
52
|
+
): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
|
|
52
53
|
const response = await fetch(
|
|
53
54
|
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
|
54
55
|
{
|
|
@@ -64,9 +65,18 @@ async function splitHtmlElement(
|
|
|
64
65
|
},
|
|
65
66
|
);
|
|
66
67
|
if (!response.ok) throw new Error("Split request failed");
|
|
67
|
-
|
|
68
|
+
const data = (await response.json()) as {
|
|
69
|
+
ok: boolean;
|
|
70
|
+
changed?: boolean;
|
|
71
|
+
content?: string;
|
|
72
|
+
version?: string;
|
|
73
|
+
};
|
|
74
|
+
const version = data.version ?? response.headers.get("etag");
|
|
75
|
+
if (!version) throw new Error("Split response did not include a content version");
|
|
76
|
+
return { ...data, version };
|
|
68
77
|
}
|
|
69
78
|
|
|
79
|
+
// fallow-ignore-next-line complexity
|
|
70
80
|
async function splitGsapAnimations(
|
|
71
81
|
projectId: string,
|
|
72
82
|
targetPath: string,
|
|
@@ -75,7 +85,7 @@ async function splitGsapAnimations(
|
|
|
75
85
|
splitTime: number,
|
|
76
86
|
elementStart: number,
|
|
77
87
|
elementDuration: number,
|
|
78
|
-
): Promise<{ content: string | null; skippedSelectors?: string[] }> {
|
|
88
|
+
): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
|
|
79
89
|
const response = await fetch(
|
|
80
90
|
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
|
|
81
91
|
{
|
|
@@ -101,10 +111,12 @@ async function splitGsapAnimations(
|
|
|
101
111
|
const data = (await response.json()) as {
|
|
102
112
|
ok?: boolean;
|
|
103
113
|
after?: string;
|
|
114
|
+
version?: string;
|
|
104
115
|
skippedSelectors?: string[];
|
|
105
116
|
};
|
|
106
117
|
return {
|
|
107
118
|
content: data.ok && data.after ? data.after : null,
|
|
119
|
+
version: data.version ?? response.headers.get("etag") ?? undefined,
|
|
108
120
|
skippedSelectors: data.skippedSelectors,
|
|
109
121
|
};
|
|
110
122
|
}
|
|
@@ -119,11 +131,11 @@ function getOriginalContent(originals: ReadonlyMap<string, string>, path: string
|
|
|
119
131
|
|
|
120
132
|
async function restoreFilesToOriginal(
|
|
121
133
|
originals: ReadonlyMap<string, string>,
|
|
122
|
-
|
|
123
|
-
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
134
|
+
snapshots: ReadonlyMap<string, { before: string; after: string }>,
|
|
135
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
124
136
|
): Promise<void> {
|
|
125
|
-
for (const path of
|
|
126
|
-
await writeProjectFile(path, getOriginalContent(originals, path));
|
|
137
|
+
for (const [path, snapshot] of snapshots) {
|
|
138
|
+
await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
|
|
127
139
|
}
|
|
128
140
|
}
|
|
129
141
|
|
|
@@ -149,17 +161,25 @@ async function splitElementsAtTime(
|
|
|
149
161
|
activeCompPath: string | null,
|
|
150
162
|
originals: ReadonlyMap<string, string>,
|
|
151
163
|
snapshots: Map<string, { before: string; after: string }>,
|
|
152
|
-
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
164
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
165
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
|
153
166
|
): Promise<number> {
|
|
154
167
|
let count = 0;
|
|
155
168
|
for (const element of elements) {
|
|
156
|
-
const result = await executeSplit(
|
|
169
|
+
const result = await executeSplit(
|
|
170
|
+
pid,
|
|
171
|
+
element,
|
|
172
|
+
splitTime,
|
|
173
|
+
activeCompPath,
|
|
174
|
+
writeProjectFile,
|
|
175
|
+
observeProjectFileVersion,
|
|
176
|
+
);
|
|
157
177
|
if (!result.changed) continue;
|
|
158
178
|
snapshots.set(result.targetPath, {
|
|
159
179
|
before: getOriginalContent(originals, result.targetPath),
|
|
160
180
|
after: result.patchedContent,
|
|
161
181
|
});
|
|
162
|
-
await writeProjectFile(result.targetPath, result.patchedContent);
|
|
182
|
+
await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
|
|
163
183
|
count++;
|
|
164
184
|
}
|
|
165
185
|
return count;
|
|
@@ -171,7 +191,8 @@ async function executeSplit(
|
|
|
171
191
|
element: TimelineElement,
|
|
172
192
|
splitTime: number,
|
|
173
193
|
activeCompPath: string | null,
|
|
174
|
-
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
194
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
195
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
|
175
196
|
): Promise<{
|
|
176
197
|
targetPath: string;
|
|
177
198
|
originalContent: string;
|
|
@@ -209,6 +230,7 @@ async function executeSplit(
|
|
|
209
230
|
if (!splitResult.changed) {
|
|
210
231
|
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
|
|
211
232
|
}
|
|
233
|
+
observeProjectFileVersion?.(targetPath, splitResult.version);
|
|
212
234
|
|
|
213
235
|
let patchedContent =
|
|
214
236
|
typeof splitResult.content === "string" ? splitResult.content : originalContent;
|
|
@@ -226,11 +248,12 @@ async function executeSplit(
|
|
|
226
248
|
element.duration,
|
|
227
249
|
);
|
|
228
250
|
if (gsapResult.content) patchedContent = gsapResult.content;
|
|
251
|
+
if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
|
|
229
252
|
if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
|
|
230
253
|
} catch (gsapError) {
|
|
231
254
|
// GSAP mutation failed — the HTML split already wrote to disk.
|
|
232
255
|
// Restore the original content to avoid a corrupt half-split state.
|
|
233
|
-
await writeProjectFile(targetPath, originalContent);
|
|
256
|
+
await writeProjectFile(targetPath, originalContent, patchedContent);
|
|
234
257
|
throw gsapError;
|
|
235
258
|
}
|
|
236
259
|
}
|
|
@@ -243,6 +266,7 @@ export function useRazorSplit({
|
|
|
243
266
|
activeCompPath,
|
|
244
267
|
showToast,
|
|
245
268
|
writeProjectFile,
|
|
269
|
+
observeProjectFileVersion,
|
|
246
270
|
recordEdit,
|
|
247
271
|
domEditSaveTimestampRef,
|
|
248
272
|
reloadPreview,
|
|
@@ -265,7 +289,14 @@ export function useRazorSplit({
|
|
|
265
289
|
|
|
266
290
|
try {
|
|
267
291
|
const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
|
|
268
|
-
await executeSplit(
|
|
292
|
+
await executeSplit(
|
|
293
|
+
pid,
|
|
294
|
+
element,
|
|
295
|
+
splitTime,
|
|
296
|
+
activeCompPath,
|
|
297
|
+
writeProjectFile,
|
|
298
|
+
observeProjectFileVersion,
|
|
299
|
+
);
|
|
269
300
|
|
|
270
301
|
if (!changed) {
|
|
271
302
|
showToast("Failed to split clip — playhead may be outside the clip", "error");
|
|
@@ -305,6 +336,7 @@ export function useRazorSplit({
|
|
|
305
336
|
recordEdit,
|
|
306
337
|
showToast,
|
|
307
338
|
writeProjectFile,
|
|
339
|
+
observeProjectFileVersion,
|
|
308
340
|
domEditSaveTimestampRef,
|
|
309
341
|
reloadPreview,
|
|
310
342
|
forceReloadSdkSession,
|
|
@@ -312,8 +344,8 @@ export function useRazorSplit({
|
|
|
312
344
|
],
|
|
313
345
|
);
|
|
314
346
|
|
|
315
|
-
// fallow-ignore-next-line complexity
|
|
316
347
|
const handleRazorSplitAll = useCallback(
|
|
348
|
+
// fallow-ignore-next-line complexity
|
|
317
349
|
async (splitTime: number) => {
|
|
318
350
|
if (isRecordingRef?.current) {
|
|
319
351
|
showToast("Cannot edit timeline while recording", "error");
|
|
@@ -338,6 +370,7 @@ export function useRazorSplit({
|
|
|
338
370
|
originals,
|
|
339
371
|
finalSnapshots,
|
|
340
372
|
writeProjectFile,
|
|
373
|
+
observeProjectFileVersion,
|
|
341
374
|
);
|
|
342
375
|
if (splitCount === 0) return;
|
|
343
376
|
|
|
@@ -358,7 +391,7 @@ export function useRazorSplit({
|
|
|
358
391
|
// Best-effort rollback — a failing restore write must not swallow the
|
|
359
392
|
// original error's toast, which is what tells the user the split failed.
|
|
360
393
|
try {
|
|
361
|
-
await restoreFilesToOriginal(originals, finalSnapshots
|
|
394
|
+
await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
|
|
362
395
|
} catch {
|
|
363
396
|
/* leave disk as-is; the original failure is reported below */
|
|
364
397
|
}
|
|
@@ -371,6 +404,7 @@ export function useRazorSplit({
|
|
|
371
404
|
recordEdit,
|
|
372
405
|
showToast,
|
|
373
406
|
writeProjectFile,
|
|
407
|
+
observeProjectFileVersion,
|
|
374
408
|
domEditSaveTimestampRef,
|
|
375
409
|
reloadPreview,
|
|
376
410
|
forceReloadSdkSession,
|
|
@@ -46,6 +46,7 @@ export function useTimelineEditing({
|
|
|
46
46
|
timelineElements,
|
|
47
47
|
showToast,
|
|
48
48
|
writeProjectFile,
|
|
49
|
+
observeProjectFileVersion,
|
|
49
50
|
recordEdit,
|
|
50
51
|
domEditSaveTimestampRef,
|
|
51
52
|
reloadPreview,
|
|
@@ -503,6 +504,7 @@ export function useTimelineEditing({
|
|
|
503
504
|
activeCompPath,
|
|
504
505
|
showToast,
|
|
505
506
|
writeProjectFile,
|
|
507
|
+
observeProjectFileVersion,
|
|
506
508
|
recordEdit,
|
|
507
509
|
domEditSaveTimestampRef,
|
|
508
510
|
reloadPreview,
|
|
@@ -31,7 +31,8 @@ export interface UseTimelineEditingOptions {
|
|
|
31
31
|
activeCompPath: string | null;
|
|
32
32
|
timelineElements: TimelineElement[];
|
|
33
33
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
34
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
35
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
35
36
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
36
37
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
37
38
|
reloadPreview: () => void;
|
|
@@ -60,7 +60,7 @@ interface UseTimelineGroupEditingOptions {
|
|
|
60
60
|
sdkSession?: Composition | null;
|
|
61
61
|
publishSdkSession?: PublishSdkSession;
|
|
62
62
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
63
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
63
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
|
|
@@ -114,4 +114,23 @@ describe("dom edit save queue", () => {
|
|
|
114
114
|
expect(onOpen).not.toHaveBeenCalled();
|
|
115
115
|
queue.destroy();
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
it("pauses immediately on a file conflict instead of retrying stale work", async () => {
|
|
119
|
+
const onOpen = vi.fn();
|
|
120
|
+
const queue = createDomEditSaveQueue({ failureThreshold: 5, onOpen });
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
queue.enqueue(async () => {
|
|
124
|
+
throw new StudioSaveHttpError("File changed elsewhere", 409);
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow("File changed elsewhere");
|
|
127
|
+
|
|
128
|
+
expect(onOpen).toHaveBeenCalledWith({
|
|
129
|
+
consecutiveFailures: 1,
|
|
130
|
+
errorMessage: "File changed elsewhere",
|
|
131
|
+
statusCode: 409,
|
|
132
|
+
});
|
|
133
|
+
await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
|
|
134
|
+
queue.destroy();
|
|
135
|
+
});
|
|
117
136
|
});
|
|
@@ -59,7 +59,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
59
59
|
return result;
|
|
60
60
|
} catch (error) {
|
|
61
61
|
consecutiveFailures += 1;
|
|
62
|
-
if (consecutiveFailures >= failureThreshold)
|
|
62
|
+
if (getStudioSaveStatusCode(error) === 409 || consecutiveFailures >= failureThreshold)
|
|
63
|
+
open(error);
|
|
63
64
|
throw error;
|
|
64
65
|
}
|
|
65
66
|
};
|
|
@@ -223,7 +223,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
223
223
|
target: "hf-abc",
|
|
224
224
|
styles: { color: "red", opacity: "0.5" },
|
|
225
225
|
});
|
|
226
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>");
|
|
226
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>", "before");
|
|
227
227
|
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
228
228
|
});
|
|
229
229
|
|
|
@@ -852,7 +852,11 @@ describe("sdkDeletePersist", () => {
|
|
|
852
852
|
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
|
853
853
|
expect(result.status).toBe("committed");
|
|
854
854
|
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
|
|
855
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
855
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
856
|
+
"/comp.html",
|
|
857
|
+
"<html>after</html>",
|
|
858
|
+
"before",
|
|
859
|
+
);
|
|
856
860
|
});
|
|
857
861
|
|
|
858
862
|
it("records edit history with before/after diff", async () => {
|
|
@@ -937,7 +941,11 @@ describe("sdkTimingPersist", () => {
|
|
|
937
941
|
duration: 5,
|
|
938
942
|
trackIndex: 1,
|
|
939
943
|
});
|
|
940
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
944
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
945
|
+
"/comp.html",
|
|
946
|
+
"<html>after</html>",
|
|
947
|
+
"<html>before</html>",
|
|
948
|
+
);
|
|
941
949
|
});
|
|
942
950
|
|
|
943
951
|
it("captures before-state before setTiming dispatch", async () => {
|
|
@@ -1158,7 +1166,11 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
1158
1166
|
"hf-box",
|
|
1159
1167
|
expect.objectContaining({ method: "to" }),
|
|
1160
1168
|
);
|
|
1161
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1169
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1170
|
+
"/comp.html",
|
|
1171
|
+
"<html>after</html>",
|
|
1172
|
+
"<html>before</html>",
|
|
1173
|
+
);
|
|
1162
1174
|
});
|
|
1163
1175
|
|
|
1164
1176
|
it("returns false for kind=add when element not found", async () => {
|
|
@@ -1262,7 +1274,11 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
1262
1274
|
position: 50,
|
|
1263
1275
|
value: { opacity: 0.5 },
|
|
1264
1276
|
});
|
|
1265
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1277
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1278
|
+
"/comp.html",
|
|
1279
|
+
"<html>after</html>",
|
|
1280
|
+
"<html>before</html>",
|
|
1281
|
+
);
|
|
1266
1282
|
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
1267
1283
|
});
|
|
1268
1284
|
|