@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.
- package/dist/assets/{hyperframes-player-PTEnAHhg.js → hyperframes-player-DKWM-Ons.js} +1 -1
- package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
- package/dist/assets/{index-CNyviK_l.js → index-HfP3sap2.js} +200 -200
- package/dist/assets/{index-BjslOS9g.js → index-UvW6BEwf.js} +1 -1
- package/dist/assets/{index--myXp8ui.js → index-Y4OnpTEM.js} +1 -1
- package/dist/index.d.ts +11 -406
- package/dist/index.html +2 -2
- package/dist/index.js +7570 -7709
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -0
- package/package.json +8 -7
- package/src/components/StudioHeader.tsx +1 -1
- package/src/components/nle/TimelinePane.tsx +0 -7
- package/src/components/renders/RenderQueue.test.tsx +133 -25
- package/src/components/renders/RenderQueue.tsx +99 -92
- package/src/components/renders/RenderQueueItem.tsx +69 -64
- package/src/components/ui/Select.tsx +4 -0
- package/src/headerExports.test.tsx +23 -0
- package/src/hooks/useFileManager.ts +123 -21
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useRightPanelIntents.ts +2 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +0 -111
- package/src/hooks/useTimelineEditing.test.tsx +3 -322
- package/src/hooks/useTimelineEditing.ts +16 -103
- package/src/hooks/useTimelineEditingTypes.ts +0 -3
- package/src/index.ts +2 -37
- package/src/main.tsx +26 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +30 -30
- package/src/styles/hex-baseline.json +2 -2
- package/src/styles/theme.css +0 -27
- package/src/hooks/timelineEditPermission.ts +0 -31
- package/src/hooks/useProjectFileWriter.ts +0 -150
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +0 -62
- package/src/hooks/useTrackPendingTimelineEdit.ts +0 -22
- 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-
|
|
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-
|
|
81
|
-
"outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
143
|
-
<span className="text-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
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
|
-
});
|