@hyperframes/studio 0.7.59 → 0.7.61

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.
Files changed (78) hide show
  1. package/dist/assets/hyperframes-player-Xvx2hkrc.js +459 -0
  2. package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
  3. package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
  4. package/dist/assets/index-BhWig0mx.css +1 -0
  5. package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1441 -770
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +4 -9
  11. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  12. package/src/components/StudioHeader.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +47 -28
  14. package/src/components/editor/PromotableControl.tsx +4 -2
  15. package/src/components/editor/PropertyPanel.tsx +151 -149
  16. package/src/components/editor/PropertyPanelFlat.tsx +44 -42
  17. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  18. package/src/components/editor/manualEditingAvailability.ts +5 -3
  19. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
  20. package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
  23. package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
  24. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  25. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
  26. package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
  27. package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
  28. package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
  29. package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
  30. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  31. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  32. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  33. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  34. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  35. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  36. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  37. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  38. package/src/components/storyboard/useFrameComments.ts +22 -6
  39. package/src/contexts/FileManagerContext.tsx +3 -0
  40. package/src/contexts/PanelLayoutContext.tsx +3 -0
  41. package/src/contexts/ViewModeContext.tsx +60 -7
  42. package/src/hooks/timelineEditingHelpers.ts +2 -2
  43. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  44. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  45. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  46. package/src/hooks/useDomEditCommits.ts +2 -2
  47. package/src/hooks/useDomEditSession.ts +1 -1
  48. package/src/hooks/useEditorSave.ts +1 -1
  49. package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
  50. package/src/hooks/useFileManager.ts +76 -12
  51. package/src/hooks/usePanelLayout.test.ts +118 -0
  52. package/src/hooks/usePanelLayout.ts +24 -1
  53. package/src/hooks/usePreviewPersistence.ts +4 -1
  54. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  55. package/src/hooks/useRazorSplit.test.tsx +88 -0
  56. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  57. package/src/hooks/useRazorSplit.ts +50 -16
  58. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  59. package/src/hooks/useSlideshowTabState.ts +61 -0
  60. package/src/hooks/useTimelineEditing.ts +2 -0
  61. package/src/hooks/useTimelineEditingTypes.ts +2 -1
  62. package/src/hooks/useTimelineGroupEditing.ts +1 -1
  63. package/src/player/lib/playbackTypes.ts +7 -0
  64. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  65. package/src/player/lib/timelineDOM.ts +8 -11
  66. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  67. package/src/utils/domEditSaveQueue.test.ts +19 -0
  68. package/src/utils/domEditSaveQueue.ts +2 -1
  69. package/src/utils/sdkCutover.test.ts +21 -5
  70. package/src/utils/sdkEditTransaction.ts +5 -4
  71. package/src/utils/studioFileHistory.ts +6 -4
  72. package/src/utils/studioFileVersion.test.ts +36 -0
  73. package/src/utils/studioFileVersion.ts +23 -0
  74. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  75. package/src/utils/studioSaveDiagnostics.ts +21 -0
  76. package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
  77. package/dist/assets/index-Dj5p8U_A.css +0 -1
  78. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef } from "react";
1
+ import { useState, useCallback, useMemo, useRef } from "react";
2
2
  import type { EditingFile } from "../utils/studioHelpers";
3
3
  import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
@@ -7,8 +7,10 @@ import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
7
7
  import {
8
8
  createStudioSaveHttpError,
9
9
  retryStudioSave,
10
+ StudioFileConflictError,
10
11
  StudioSaveNetworkError,
11
12
  } from "../utils/studioSaveDiagnostics";
13
+ import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
12
14
  import { useFileTree } from "./useFileTree";
13
15
  import { useEditorSave } from "./useEditorSave";
14
16
 
@@ -50,6 +52,17 @@ export function useFileManager({
50
52
  projectIdRef.current = projectId;
51
53
 
52
54
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
55
+ const fileVersionScope = useMemo(
56
+ () => ({ projectId, versions: new Map<string, string | null>() }),
57
+ [projectId],
58
+ );
59
+ const fileVersions = fileVersionScope.versions;
60
+ const observeProjectFileVersion = useCallback(
61
+ (path: string, version: string | null) => {
62
+ fileVersions.set(path, version);
63
+ },
64
+ [fileVersions],
65
+ );
53
66
 
54
67
  // ── File tree ──
55
68
 
@@ -73,17 +86,38 @@ export function useFileManager({
73
86
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
74
87
  );
75
88
  if (!response.ok) throw new Error(`Failed to read ${path}`);
76
- const data = (await response.json()) as { content?: string };
89
+ const data = (await response.json()) as { content?: string; version?: string };
77
90
  if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
91
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
78
92
  return data.content;
79
93
  },
80
- [projectId],
94
+ [fileVersions, projectId],
81
95
  );
82
96
 
83
97
  const writeProjectFile = useCallback(
84
- async (path: string, content: string): Promise<void> => {
98
+ async (path: string, content: string, expectedContent?: string): Promise<void> => {
85
99
  if (!projectId) throw new Error("No active project");
86
100
  const writeProjectId = projectId;
101
+ let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
102
+ if (expectedVersion === undefined) {
103
+ const preflight = await fetch(
104
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
105
+ );
106
+ if (preflight.ok) {
107
+ const data = (await preflight.json()) as { content?: string; version?: string };
108
+ throw new StudioFileConflictError({
109
+ filePath: path,
110
+ currentVersion: data.version ?? preflight.headers.get("etag"),
111
+ currentContent: data.content ?? null,
112
+ attemptedContent: content,
113
+ });
114
+ } else if (preflight.status === 404) {
115
+ expectedVersion = null;
116
+ } else {
117
+ throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
118
+ }
119
+ }
120
+ const writeToken = createStudioWriteToken();
87
121
  await retryStudioSave(async () => {
88
122
  let response: Response;
89
123
  try {
@@ -91,7 +125,11 @@ export function useFileManager({
91
125
  `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
92
126
  {
93
127
  method: "PUT",
94
- headers: { "Content-Type": "text/plain" },
128
+ headers: {
129
+ "Content-Type": "text/plain",
130
+ "X-Hyperframes-Write-Token": writeToken,
131
+ ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
132
+ },
95
133
  body: content,
96
134
  },
97
135
  );
@@ -100,13 +138,35 @@ export function useFileManager({
100
138
  cause: error,
101
139
  });
102
140
  }
141
+ if (response.status === 409) {
142
+ const conflict = (await response.json().catch(() => null)) as {
143
+ currentVersion?: string | null;
144
+ currentContent?: string | null;
145
+ } | null;
146
+ const currentVersion = conflict?.currentVersion ?? null;
147
+ if (currentVersion && conflict?.currentContent === content) {
148
+ fileVersions.set(path, currentVersion);
149
+ return;
150
+ }
151
+ throw new StudioFileConflictError({
152
+ filePath: path,
153
+ currentVersion,
154
+ currentContent: conflict?.currentContent ?? null,
155
+ attemptedContent: content,
156
+ });
157
+ }
103
158
  if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
159
+ const result = (await response.json()) as { version?: string };
160
+ const version = result.version ?? response.headers.get("etag");
161
+ if (!version)
162
+ throw new Error(`Save response for ${path} did not include a content version`);
163
+ fileVersions.set(path, version);
104
164
  });
105
165
  if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
106
166
  setEditingFile({ path, content });
107
167
  }
108
168
  },
109
- [projectId],
169
+ [fileVersions, projectId],
110
170
  );
111
171
 
112
172
  const updateEditingFileContent = useCallback((path: string, content: string) => {
@@ -122,10 +182,11 @@ export function useFileManager({
122
182
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
123
183
  );
124
184
  if (!response.ok) throw new Error(`Failed to read ${path}`);
125
- const data = (await response.json()) as { content?: string };
185
+ const data = (await response.json()) as { content?: string; version?: string };
186
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
126
187
  return typeof data.content === "string" ? data.content : "";
127
188
  },
128
- [projectId],
189
+ [fileVersions, projectId],
129
190
  );
130
191
 
131
192
  // ── Editor save (debounced content change) ──
@@ -163,8 +224,9 @@ export function useFileManager({
163
224
  if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
164
225
  return r.json();
165
226
  })
166
- .then((data: { content?: string }) => {
227
+ .then((data: { content?: string; version?: string }) => {
167
228
  if (data.content != null) {
229
+ fileVersions.set(path, data.version ?? null);
168
230
  setEditingFile({ path, content: data.content });
169
231
  }
170
232
  })
@@ -172,7 +234,7 @@ export function useFileManager({
172
234
  showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
173
235
  });
174
236
  },
175
- [showToast],
237
+ [fileVersions, showToast],
176
238
  );
177
239
 
178
240
  // ── Click-to-source ──
@@ -195,9 +257,10 @@ export function useFileManager({
195
257
  signal: controller.signal,
196
258
  })
197
259
  .then((r) => r.json())
198
- .then((data: { content?: string }) => {
260
+ .then((data: { content?: string; version?: string }) => {
199
261
  if (requestId !== revealRequestIdRef.current) return;
200
262
  if (data.content != null) {
263
+ fileVersions.set(sourceFile, data.version ?? null);
201
264
  setEditingFile({ path: sourceFile, content: data.content });
202
265
  const match = findTagByTarget(data.content, target);
203
266
  setRevealSourceOffset(match ? match.start : null);
@@ -205,7 +268,7 @@ export function useFileManager({
205
268
  })
206
269
  .catch(() => {});
207
270
  },
208
- [editingFile?.content],
271
+ [editingFile?.content, fileVersions],
209
272
  );
210
273
 
211
274
  // ── Upload ──
@@ -434,6 +497,7 @@ export function useFileManager({
434
497
  readProjectFile,
435
498
  writeProjectFile,
436
499
  readOptionalProjectFile,
500
+ observeProjectFileVersion,
437
501
  updateEditingFileContent,
438
502
 
439
503
  // Click-to-source
@@ -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
- setRightInspectorPanes((panes) => ({ ...panes, [tab]: true }));
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 = "Auto-save is paused. Check your connection.";
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
- return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
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({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
94
- { status: 200, headers: { "Content-Type": "application/json" } },
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/")) {