@hyperframes/studio 0.8.53-desktop.0 → 0.8.53

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 (38) hide show
  1. package/dist/assets/{hyperframes-player-PTEnAHhg.js → hyperframes-player-DKWM-Ons.js} +1 -1
  2. package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
  3. package/dist/assets/{index-CNyviK_l.js → index-HfP3sap2.js} +200 -200
  4. package/dist/assets/{index-BjslOS9g.js → index-UvW6BEwf.js} +1 -1
  5. package/dist/assets/{index--myXp8ui.js → index-Y4OnpTEM.js} +1 -1
  6. package/dist/index.d.ts +11 -406
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7570 -7709
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -0
  11. package/package.json +8 -7
  12. package/src/components/StudioHeader.tsx +1 -1
  13. package/src/components/nle/TimelinePane.tsx +0 -7
  14. package/src/components/renders/RenderQueue.test.tsx +133 -25
  15. package/src/components/renders/RenderQueue.tsx +99 -92
  16. package/src/components/renders/RenderQueueItem.tsx +69 -64
  17. package/src/components/ui/Select.tsx +4 -0
  18. package/src/headerExports.test.tsx +23 -0
  19. package/src/hooks/useFileManager.ts +123 -21
  20. package/src/hooks/usePersistentEditHistory.ts +1 -1
  21. package/src/hooks/useRightPanelIntents.ts +2 -1
  22. package/src/hooks/useTimelineDeleteOps.test.tsx +0 -111
  23. package/src/hooks/useTimelineEditing.test.tsx +3 -322
  24. package/src/hooks/useTimelineEditing.ts +16 -103
  25. package/src/hooks/useTimelineEditingTypes.ts +0 -3
  26. package/src/index.ts +2 -37
  27. package/src/main.tsx +26 -0
  28. package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
  29. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  30. package/src/player/components/timelineTheme.test.ts +4 -4
  31. package/src/player/components/timelineTheme.ts +30 -30
  32. package/src/styles/hex-baseline.json +2 -2
  33. package/src/styles/theme.css +0 -27
  34. package/src/hooks/timelineEditPermission.ts +0 -31
  35. package/src/hooks/useProjectFileWriter.ts +0 -150
  36. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +0 -62
  37. package/src/hooks/useTrackPendingTimelineEdit.ts +0 -22
  38. package/src/hostMount.test.tsx +0 -222
@@ -2,6 +2,8 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { memo, useCallback, useState } from "react";
3
3
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
4
4
  import { Button } from "../ui/Button";
5
+ import { IconButton } from "../ui/IconButton";
6
+ import { Tooltip } from "../ui/Tooltip";
5
7
  import type { RenderJob } from "./useRenderQueue";
6
8
 
7
9
  interface RenderQueueItemProps {
@@ -66,7 +68,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
66
68
  setVideoReady(false);
67
69
  setConfirmingDelete(false);
68
70
  }}
69
- className="px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30"
71
+ className="px-3 py-2.5 border-b border-border last:border-0 transition-colors duration-150 hover:bg-hover/30"
70
72
  >
71
73
  <div className="flex items-center gap-2.5">
72
74
  {/* Thumbnail — static frame; swaps to live video on hover.
@@ -77,8 +79,8 @@ export const RenderQueueItem = memo(function RenderQueueItem({
77
79
  disabled={!isComplete}
78
80
  aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined}
79
81
  className={[
80
- "w-20 h-[45px] rounded-md overflow-hidden bg-panel-input shrink-0 relative",
81
- "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent",
82
+ "w-20 h-[45px] rounded-md overflow-hidden bg-input shrink-0 relative",
83
+ "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent",
82
84
  isComplete ? "cursor-pointer" : "cursor-default",
83
85
  ].join(" ")}
84
86
  >
@@ -108,17 +110,17 @@ export const RenderQueueItem = memo(function RenderQueueItem({
108
110
  )}
109
111
  {isRendering && (
110
112
  <div className="w-full h-full flex items-center justify-center">
111
- <div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse motion-reduce:animate-none" />
113
+ <div className="w-2 h-2 rounded-full bg-accent animate-pulse motion-reduce:animate-none" />
112
114
  </div>
113
115
  )}
114
116
  {job.status === "failed" && (
115
117
  <div className="w-full h-full flex items-center justify-center">
116
- <div className="w-2 h-2 rounded-full bg-red-400" />
118
+ <div className="w-2 h-2 rounded-full bg-danger" />
117
119
  </div>
118
120
  )}
119
121
  {job.status === "cancelled" && (
120
122
  <div className="w-full h-full flex items-center justify-center">
121
- <div className="w-2 h-2 rounded-full bg-neutral-600" />
123
+ <div className="w-2 h-2 rounded-full bg-text-5" />
122
124
  </div>
123
125
  )}
124
126
  </button>
@@ -126,11 +128,9 @@ export const RenderQueueItem = memo(function RenderQueueItem({
126
128
  {/* Info */}
127
129
  <div className="flex-1 min-w-0">
128
130
  <div className="flex items-center gap-1.5">
129
- <span className="text-[11px] font-medium text-panel-text-2 truncate">
130
- {job.filename}
131
- </span>
131
+ <span className="text-step-11 font-medium text-text-2 truncate">{job.filename}</span>
132
132
  {job.durationMs && (
133
- <span className="text-[9px] text-panel-text-5 shrink-0">
133
+ <span className="text-step-9 text-text-5 shrink-0">
134
134
  {formatDuration(job.durationMs)}
135
135
  </span>
136
136
  )}
@@ -139,11 +139,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
139
139
  {isRendering && (
140
140
  <div className="mt-1">
141
141
  <div className="flex items-center justify-between mb-0.5">
142
- <span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
143
- <span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
142
+ <span className="text-step-9 text-text-4">{job.stage || "Rendering"}</span>
143
+ <span className="text-step-9 font-mono text-accent">{job.progress}%</span>
144
144
  </div>
145
145
  <div
146
- className="w-full h-1 bg-panel-border rounded-full overflow-hidden"
146
+ className="w-full h-1 bg-border rounded-full overflow-hidden"
147
147
  role="progressbar"
148
148
  aria-valuenow={job.progress}
149
149
  aria-valuemin={0}
@@ -151,7 +151,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
151
151
  aria-label={`Render progress: ${job.progress}%`}
152
152
  >
153
153
  <div
154
- className="h-full bg-panel-accent rounded-full transition-all duration-300"
154
+ className="h-full bg-accent rounded-full transition-all duration-300"
155
155
  style={{ width: `${job.progress}%` }}
156
156
  />
157
157
  </div>
@@ -159,14 +159,14 @@ export const RenderQueueItem = memo(function RenderQueueItem({
159
159
  )}
160
160
 
161
161
  {job.status === "failed" && job.error && (
162
- <span className="text-[9px] text-red-400 mt-0.5 block">{job.error}</span>
162
+ <span className="text-step-9 text-danger mt-0.5 block">{job.error}</span>
163
163
  )}
164
164
  {job.status === "cancelled" && (
165
- <span className="text-[9px] text-panel-text-4 mt-0.5 block">Cancelled</span>
165
+ <span className="text-step-9 text-text-4 mt-0.5 block">Cancelled</span>
166
166
  )}
167
167
 
168
168
  {!isRendering && (
169
- <span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
169
+ <span className="text-step-9 text-text-5">{formatTimeAgo(job.createdAt)}</span>
170
170
  )}
171
171
  </div>
172
172
 
@@ -209,53 +209,58 @@ export const RenderQueueItem = memo(function RenderQueueItem({
209
209
  </>
210
210
  ) : (
211
211
  <>
212
- <button
213
- onClick={isComplete ? handleDownload : undefined}
214
- className={`p-1.5 min-w-6 min-h-6 rounded transition-colors outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent ${
215
- isComplete
216
- ? "text-panel-text-5 hover:text-panel-accent"
217
- : "text-panel-text-5/30 cursor-default"
218
- }`}
219
- title={isComplete ? "Download" : undefined}
220
- aria-label={`Download ${job.filename}`}
221
- disabled={!isComplete}
222
- >
223
- <svg
224
- width="12"
225
- height="12"
226
- viewBox="0 0 24 24"
227
- fill="none"
228
- stroke="currentColor"
229
- strokeWidth="2"
230
- strokeLinecap="round"
231
- strokeLinejoin="round"
232
- >
233
- <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
234
- <polyline points="7 10 12 15 17 10" />
235
- <line x1="12" y1="15" x2="12" y2="3" />
236
- </svg>
237
- </button>
238
- <button
239
- onClick={(e) => {
240
- e.stopPropagation();
241
- setConfirmingDelete(true);
242
- }}
243
- className="p-1.5 min-w-6 min-h-6 rounded-sm text-panel-text-5 hover:text-red-400 transition-colors outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent"
244
- title="Delete render file"
245
- aria-label={`Delete ${job.filename}`}
246
- >
247
- <svg
248
- width="12"
249
- height="12"
250
- viewBox="0 0 24 24"
251
- fill="none"
252
- stroke="currentColor"
253
- strokeWidth="2"
254
- strokeLinecap="round"
255
- >
256
- <path d="M18 6L6 18M6 6l12 12" />
257
- </svg>
258
- </button>
212
+ {/* The tooltip explains the control whether or not it is
213
+ reachable; `disabled:` on the shared Button keeps pointer
214
+ events alive precisely so a disabled one can still say why. */}
215
+ <Tooltip label={isComplete ? "Download" : "Available when the render finishes"}>
216
+ <IconButton
217
+ size="sm"
218
+ onClick={handleDownload}
219
+ disabled={!isComplete}
220
+ aria-label={`Download ${job.filename}`}
221
+ icon={
222
+ <svg
223
+ width="12"
224
+ height="12"
225
+ viewBox="0 0 24 24"
226
+ fill="none"
227
+ stroke="currentColor"
228
+ strokeWidth="2"
229
+ strokeLinecap="round"
230
+ strokeLinejoin="round"
231
+ aria-hidden="true"
232
+ >
233
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
234
+ <polyline points="7 10 12 15 17 10" />
235
+ <line x1="12" y1="15" x2="12" y2="3" />
236
+ </svg>
237
+ }
238
+ />
239
+ </Tooltip>
240
+ <Tooltip label="Delete render file">
241
+ <IconButton
242
+ size="sm"
243
+ onClick={(e) => {
244
+ e.stopPropagation();
245
+ setConfirmingDelete(true);
246
+ }}
247
+ aria-label={`Delete ${job.filename}`}
248
+ icon={
249
+ <svg
250
+ width="12"
251
+ height="12"
252
+ viewBox="0 0 24 24"
253
+ fill="none"
254
+ stroke="currentColor"
255
+ strokeWidth="2"
256
+ strokeLinecap="round"
257
+ aria-hidden="true"
258
+ >
259
+ <path d="M18 6L6 18M6 6l12 12" />
260
+ </svg>
261
+ }
262
+ />
263
+ </Tooltip>
259
264
  </>
260
265
  )}
261
266
  </div>
@@ -11,6 +11,8 @@ import type { PreviewState } from "./Button";
11
11
  export interface SelectOption {
12
12
  label: string;
13
13
  value: string;
14
+ /** Offered but not choosable; the label stays visible so the reason stays visible too. */
15
+ disabled?: boolean;
14
16
  }
15
17
 
16
18
  export interface SelectProps {
@@ -81,11 +83,13 @@ export function Select({
81
83
  <BaseSelect.Item
82
84
  key={option.value}
83
85
  value={option.value}
86
+ disabled={option.disabled}
84
87
  className={cn(
85
88
  "flex h-ctl-sm cursor-pointer select-none items-center gap-2 px-2.5",
86
89
  "text-step-11 text-text-2 outline-hidden",
87
90
  "data-[highlighted]:bg-hover data-[highlighted]:text-text-0",
88
91
  "data-[selected]:text-text-0",
92
+ "data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40",
89
93
  )}
90
94
  >
91
95
  <BaseSelect.ItemText className="truncate">{option.label}</BaseSelect.ItemText>
@@ -0,0 +1,23 @@
1
+ // @vitest-environment happy-dom
2
+ // Imports the header logo and button size classes the way a host app does: by package name.
3
+ import { createRoot } from "react-dom/client";
4
+ import { act } from "react";
5
+ import { describe, expect, it } from "vitest";
6
+ import { HyperframesLogo, buttonSizes } from "@hyperframes/studio";
7
+
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9
+
10
+ describe("header package exports", () => {
11
+ it("mounts the logo mark", async () => {
12
+ const el = document.createElement("div");
13
+ document.body.append(el);
14
+ const root = createRoot(el);
15
+ await act(async () => root.render(<HyperframesLogo />));
16
+ expect(el.querySelector("svg")).not.toBeNull();
17
+ await act(async () => root.unmount());
18
+ });
19
+
20
+ it("exposes the button size classes a host reuses for matching chrome", () => {
21
+ expect(buttonSizes.md).toEqual(expect.any(String));
22
+ });
23
+ });
@@ -1,13 +1,18 @@
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";
5
5
  import type { EditHistoryKind } from "../utils/editHistory";
6
6
  import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
7
- import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
7
+ import {
8
+ createStudioSaveHttpError,
9
+ retryStudioSave,
10
+ StudioFileConflictError,
11
+ StudioSaveNetworkError,
12
+ } from "../utils/studioSaveDiagnostics";
13
+ import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
8
14
  import { useFileTree } from "./useFileTree";
9
15
  import { useEditorSave } from "./useEditorSave";
10
- import { useProjectFileWriter } from "./useProjectFileWriter";
11
16
 
12
17
  // ── Types ──
13
18
 
@@ -45,13 +50,17 @@ export function useFileManager({
45
50
  projectIdRef.current = projectId;
46
51
 
47
52
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
48
- const {
49
- fileVersions,
50
- readProjectFile,
51
- writeProjectFile: writeProjectFileRaw,
52
- readOptionalProjectFile,
53
- observeProjectFileVersion,
54
- } = useProjectFileWriter({ projectId });
53
+ const fileVersionScope = useMemo(
54
+ () => ({ projectId, versions: new Map<string, string | null>() }),
55
+ [projectId],
56
+ );
57
+ const fileVersions = fileVersionScope.versions;
58
+ const observeProjectFileVersion = useCallback(
59
+ (path: string, version: string | null) => {
60
+ fileVersions.set(path, version);
61
+ },
62
+ [fileVersions],
63
+ );
55
64
 
56
65
  // ── File tree ──
57
66
 
@@ -65,24 +74,117 @@ export function useFileManager({
65
74
  fontAssets,
66
75
  } = useFileTree({ projectId, projectIdRef });
67
76
 
68
- const updateEditingFileContent = useCallback((path: string, content: string) => {
69
- if (editingPathRef.current === path) {
70
- setEditingFile({ path, content });
71
- }
72
- }, []);
77
+ // ── Core file I/O ──
78
+
79
+ const readProjectFile = useCallback(
80
+ async (path: string): Promise<string> => {
81
+ if (!projectId) throw new Error("No active project");
82
+ const response = await fetch(
83
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
84
+ );
85
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
86
+ const data = (await response.json()) as { content?: string; version?: string };
87
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
88
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
89
+ return data.content;
90
+ },
91
+ [fileVersions, projectId],
92
+ );
73
93
 
74
- // Studio's own editor tab mirrors whatever it just wrote, on top of the
75
- // shared writer — a host mounting useProjectFileWriter directly has no
76
- // editor tab and does not need this.
77
94
  const writeProjectFile = useCallback(
78
95
  async (path: string, content: string, expectedContent?: string): Promise<void> => {
79
- const writeProjectId = projectIdRef.current;
80
- await writeProjectFileRaw(path, content, expectedContent);
96
+ if (!projectId) throw new Error("No active project");
97
+ const writeProjectId = projectId;
98
+ let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
99
+ if (expectedVersion === undefined) {
100
+ const preflight = await fetch(
101
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
102
+ );
103
+ if (preflight.ok) {
104
+ const data = (await preflight.json()) as { content?: string; version?: string };
105
+ throw new StudioFileConflictError({
106
+ filePath: path,
107
+ currentVersion: data.version ?? preflight.headers.get("etag"),
108
+ currentContent: data.content ?? null,
109
+ attemptedContent: content,
110
+ });
111
+ } else if (preflight.status === 404) {
112
+ expectedVersion = null;
113
+ } else {
114
+ throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
115
+ }
116
+ }
117
+ await retryStudioSave(async () => {
118
+ // Each request gets its own receipt identity. If a committed request loses its response,
119
+ // the retry can produce a second filesystem receipt that must be suppressed independently.
120
+ let response: Response;
121
+ try {
122
+ response = await fetch(
123
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
124
+ {
125
+ method: "PUT",
126
+ headers: {
127
+ "Content-Type": "text/plain",
128
+ ...studioWriteHeaders(),
129
+ ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
130
+ },
131
+ body: content,
132
+ },
133
+ );
134
+ } catch (error) {
135
+ throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
136
+ cause: error,
137
+ });
138
+ }
139
+ if (response.status === 409) {
140
+ const conflict = (await response.json().catch(() => null)) as {
141
+ currentVersion?: string | null;
142
+ currentContent?: string | null;
143
+ } | null;
144
+ const currentVersion = conflict?.currentVersion ?? null;
145
+ if (currentVersion && conflict?.currentContent === content) {
146
+ fileVersions.set(path, currentVersion);
147
+ return;
148
+ }
149
+ throw new StudioFileConflictError({
150
+ filePath: path,
151
+ currentVersion,
152
+ currentContent: conflict?.currentContent ?? null,
153
+ attemptedContent: content,
154
+ });
155
+ }
156
+ if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
157
+ const result = (await response.json()) as { version?: string };
158
+ const version = result.version ?? response.headers.get("etag");
159
+ if (!version)
160
+ throw new Error(`Save response for ${path} did not include a content version`);
161
+ fileVersions.set(path, version);
162
+ });
81
163
  if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
82
164
  setEditingFile({ path, content });
83
165
  }
84
166
  },
85
- [writeProjectFileRaw],
167
+ [fileVersions, projectId],
168
+ );
169
+
170
+ const updateEditingFileContent = useCallback((path: string, content: string) => {
171
+ if (editingPathRef.current === path) {
172
+ setEditingFile({ path, content });
173
+ }
174
+ }, []);
175
+
176
+ const readOptionalProjectFile = useCallback(
177
+ async (path: string): Promise<string> => {
178
+ if (!projectId) throw new Error("No active project");
179
+ const response = await fetch(
180
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
181
+ );
182
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
183
+ const data = (await response.json()) as { content?: string; version?: string };
184
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
185
+ return typeof data.content === "string" ? data.content : "";
186
+ },
187
+ [fileVersions, projectId],
86
188
  );
87
189
 
88
190
  // ── Editor save (debounced content change) ──
@@ -33,7 +33,7 @@ interface ApplyCallbacks {
33
33
  serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
34
34
  }
35
35
 
36
- export interface UsePersistentEditHistoryOptions {
36
+ interface UsePersistentEditHistoryOptions {
37
37
  projectId: string | null;
38
38
  storage?: EditHistoryStorageAdapter;
39
39
  now?: () => number;
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore";
3
3
 
4
- /** Opens the Slideshow panel when the file becomes a slideshow and closes it when it stops being one; a user's own close, and a restored layout's placement, stick. */
4
+ /** Opens the Slideshow panel when the file becomes a slideshow, closes it when it stops being one.
5
+ * A user's own close, and a restored layout's placement, stick. */
5
6
  export function useSlideshowDockPanel(isSlideshowComposition: boolean) {
6
7
  const controller = useDockLayoutStore((state) => state.controller);
7
8
  const wasSlideshow = useRef(false);
@@ -5,8 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../player";
6
6
  import { applyRippleShifts, useTimelineDeleteOps } from "./useTimelineDeleteOps";
7
7
  import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
8
- import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
9
- import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
10
8
 
11
9
  installReactActEnvironment();
12
10
 
@@ -119,112 +117,3 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
119
117
  );
120
118
  });
121
119
  });
122
-
123
- // Regression: a delete's write can reach disk before its history entry is
124
- // recorded, so an Undo fired in that window found nothing to restore.
125
- // Fails without useTrackPendingTimelineEdit's wrap (see that file).
126
- describe("useTimelineDeleteOps: undo race", () => {
127
- // data-composition-id (readRootCompositionDuration's key) plus a
128
- // remove-element mock that actually drops hf-c: needed for a real diff.
129
- const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
130
- <div data-hf-id="hf-stage" data-composition-id="main" data-duration="6">
131
- <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
132
- <div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
133
- <div data-hf-id="hf-c" data-start="4" data-duration="2"></div>
134
- </div>
135
- </body></html>`;
136
- const htmlAfterDeletingC = `<!DOCTYPE html><html data-composition-variables='[]'><body>
137
- <div data-hf-id="hf-stage" data-composition-id="main" data-duration="6">
138
- <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
139
- <div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
140
- </div>
141
- </body></html>`;
142
-
143
- beforeEach(() => {
144
- vi.stubGlobal(
145
- "fetch",
146
- vi.fn(async (input: RequestInfo | URL) => {
147
- const url = String(input);
148
- if (url.includes("/file-mutations/remove-element/")) {
149
- return new Response(JSON.stringify({ changed: true, content: htmlAfterDeletingC }), {
150
- status: 200,
151
- });
152
- }
153
- return new Response(JSON.stringify({ content: html }), { status: 200 });
154
- }),
155
- );
156
- });
157
-
158
- afterEach(() => {
159
- document.body.innerHTML = "";
160
- vi.unstubAllGlobals();
161
- });
162
-
163
- // A manually-resolved gate stands in for the delete's write landing — a
164
- // real timer raced the fix's own microtasks unpredictably (verified: it
165
- // kept passing even with the fix's tracking call deleted).
166
- function deferred<T>() {
167
- let resolve!: (value: T) => void;
168
- const promise = new Promise<T>((r) => (resolve = r));
169
- return { promise, resolve };
170
- }
171
-
172
- function mountRaceHarness() {
173
- const elements = [el("hf-a", 0, 2), el("hf-b", 2, 2), el("hf-c", 4, 2)];
174
- const gate = deferred<void>();
175
- const recordEdit = vi.fn(() => gate.promise);
176
- let tracked: ((elements: TimelineElement[]) => Promise<void>) | null = null;
177
- function Harness() {
178
- const track = useTrackPendingTimelineEdit();
179
- const hook = useTimelineDeleteOps({
180
- projectIdRef: { current: "test-project" },
181
- activeCompPath: "index.html",
182
- timelineElements: elements,
183
- showToast: vi.fn(),
184
- writeProjectFile: vi.fn().mockResolvedValue(undefined),
185
- recordEdit,
186
- reloadPreview: vi.fn(),
187
- previewIframeRef: { current: null },
188
- handleTimelineGroupMove: vi.fn().mockResolvedValue(undefined),
189
- });
190
- tracked = track(hook.handleTimelineElementsDelete);
191
- return null;
192
- }
193
- mountReactHarness(<Harness />);
194
- // Deletes the LAST clip (not the middle one): only this shrinks the
195
- // composition's furthest-clip-end duration, so the write actually
196
- // differs from the original content and recordEdit is reached — the
197
- // middle clip's delete leaves duration unchanged and is a no-op write.
198
- return { gate, recordEdit, deleteSelection: () => tracked!([elements[2]]) };
199
- }
200
-
201
- it("the pending-edit flush waits while the delete's history write is still landing", async () => {
202
- const { gate, recordEdit, deleteSelection } = mountRaceHarness();
203
- const order: string[] = [];
204
- const deletePromise = deleteSelection().then(() => order.push("deleted"));
205
- // Let the delete run up to (and call) recordEdit, which is now gated —
206
- // this is the exact window a fast Undo used to race into.
207
- await vi.waitFor(() => expect(recordEdit).toHaveBeenCalled());
208
- // No React state changes happen inside the flush itself here (no
209
- // pending-edit-flush listeners are registered in this harness), so it
210
- // does not need act()'s wrapping — only awaiting the delete's own
211
- // resolution below does.
212
- const flushPromise = flushStudioPendingEdits().then(() => order.push("flushed"));
213
- await Promise.resolve();
214
- await Promise.resolve();
215
- expect(order).toEqual([]);
216
- gate.resolve();
217
- await deletePromise;
218
- await flushPromise;
219
- expect(order).toEqual(["deleted", "flushed"]);
220
- });
221
-
222
- it("the pending-edit flush resolves immediately once the delete has already finished", async () => {
223
- const { gate, deleteSelection } = mountRaceHarness();
224
- gate.resolve();
225
- await deleteSelection();
226
- await act(async () => {
227
- await flushStudioPendingEdits();
228
- });
229
- });
230
- });