@hyperframes/studio 0.8.53 → 0.8.54
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-DKWM-Ons.js → hyperframes-player-Cy_AwUa8.js} +1 -1
- package/dist/assets/{index-UvW6BEwf.js → index-CTa-6Kso.js} +1 -1
- package/dist/assets/{index-HfP3sap2.js → index-DPUJoO8n.js} +191 -191
- package/dist/assets/{index-Y4OnpTEM.js → index-DkAzKuPZ.js} +1 -1
- package/dist/index.d.ts +440 -10
- package/dist/index.html +1 -1
- package/dist/index.js +1850 -1532
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/hooks/timelineAudioGroupCreate.ts +17 -1
- package/src/hooks/timelineAudioGroupVolume.ts +35 -2
- package/src/hooks/timelineEditPermission.ts +31 -0
- package/src/hooks/timelineElementFxAttribute.ts +34 -2
- package/src/hooks/useFileManager.ts +21 -123
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useProjectFileWriter.ts +150 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
- package/src/hooks/useTimelineAssetDropOps.ts +40 -3
- package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
- package/src/hooks/useTimelineEditing.test.tsx +481 -4
- package/src/hooks/useTimelineEditing.ts +151 -27
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
- package/src/hostMount.test.tsx +222 -0
- package/src/index.ts +40 -2
- package/src/player/components/Timeline.tsx +6 -6
- package/src/player/components/TimelineTypes.ts +3 -0
- package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
- package/src/player/components/usePublishRangeSelection.ts +42 -0
- package/src/player/index.ts +1 -0
- package/src/player/store/playerStore.ts +8 -51
- package/src/player/store/rangeSelectionSlice.ts +13 -0
- package/src/player/store/timelineResetState.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.54",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"dockview-react": "8.3.1",
|
|
52
52
|
"gsap": "^3.13.0",
|
|
53
53
|
"mediabunny": "^1.45.3",
|
|
54
|
-
"@hyperframes/core": "0.8.
|
|
55
|
-
"@hyperframes/
|
|
56
|
-
"@hyperframes/player": "0.8.
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
54
|
+
"@hyperframes/core": "0.8.54",
|
|
55
|
+
"@hyperframes/parsers": "0.8.54",
|
|
56
|
+
"@hyperframes/player": "0.8.54",
|
|
57
|
+
"@hyperframes/studio-server": "0.8.54",
|
|
58
|
+
"@hyperframes/sdk": "0.8.54"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"vite": "^6.4.2",
|
|
72
72
|
"vitest": "^4.1.11",
|
|
73
73
|
"zustand": "^5.0.0",
|
|
74
|
-
"@hyperframes/producer": "0.8.
|
|
74
|
+
"@hyperframes/producer": "0.8.54"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"react": "19",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, type ReactNode } from "react";
|
|
2
2
|
import { Timeline } from "../../player";
|
|
3
|
-
import type { TimelineElement } from "../../player";
|
|
3
|
+
import type { TimelineElement, TimelineTimeRange } from "../../player";
|
|
4
4
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
5
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
6
6
|
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
|
|
@@ -77,6 +77,9 @@ export interface TimelinePaneProps {
|
|
|
77
77
|
timelineToolbar?: ReactNode;
|
|
78
78
|
/** Slot rendered below the timeline tracks */
|
|
79
79
|
timelineFooter?: ReactNode;
|
|
80
|
+
/** Slot for a host's own overlay spanning the whole timeline area (e.g. a draft/ghost
|
|
81
|
+
* clip), positioned absolutely the same way PreviewPane's previewOverlay is. */
|
|
82
|
+
timelineOverlay?: ReactNode;
|
|
80
83
|
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
|
|
81
84
|
renderClipContent?: (
|
|
82
85
|
element: TimelineElement,
|
|
@@ -101,6 +104,7 @@ export interface TimelinePaneProps {
|
|
|
101
104
|
) => Promise<void> | void;
|
|
102
105
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
103
106
|
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
|
107
|
+
onRangeSelect?: (range: TimelineTimeRange | null) => void;
|
|
104
108
|
/** Copy/paste/duplicate act on the store's own selection, not a passed
|
|
105
109
|
* element, so unlike onDeleteElement they need no composition-basis wrapper. */
|
|
106
110
|
onCopyClip?: () => boolean;
|
|
@@ -113,6 +117,7 @@ export interface TimelinePaneProps {
|
|
|
113
117
|
export function TimelinePane({
|
|
114
118
|
timelineToolbar,
|
|
115
119
|
timelineFooter,
|
|
120
|
+
timelineOverlay,
|
|
116
121
|
renderClipContent,
|
|
117
122
|
onFileDrop,
|
|
118
123
|
onDeleteElement,
|
|
@@ -121,6 +126,7 @@ export function TimelinePane({
|
|
|
121
126
|
onCompositionDrop,
|
|
122
127
|
onBlockedEditAttempt,
|
|
123
128
|
onSelectTimelineElement,
|
|
129
|
+
onRangeSelect,
|
|
124
130
|
onCopyClip,
|
|
125
131
|
onPasteClip,
|
|
126
132
|
onDuplicateClip,
|
|
@@ -278,6 +284,7 @@ export function TimelinePane({
|
|
|
278
284
|
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
279
285
|
onSplitElement={handleSplitElement}
|
|
280
286
|
onSelectElement={onSelectTimelineElement}
|
|
287
|
+
onRangeSelect={onRangeSelect}
|
|
281
288
|
onCopyClip={onCopyClip}
|
|
282
289
|
onPasteClip={onPasteClip}
|
|
283
290
|
onDuplicateClip={onDuplicateClip}
|
|
@@ -285,6 +292,9 @@ export function TimelinePane({
|
|
|
285
292
|
/>
|
|
286
293
|
</div>
|
|
287
294
|
{timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
|
|
295
|
+
{timelineOverlay && (
|
|
296
|
+
<div className="pointer-events-none absolute inset-0 z-20">{timelineOverlay}</div>
|
|
297
|
+
)}
|
|
288
298
|
{timelineDisabled && (
|
|
289
299
|
<div
|
|
290
300
|
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
|
|
@@ -28,6 +28,19 @@ import {
|
|
|
28
28
|
type UseTimelineElementVisibilityEditingInput,
|
|
29
29
|
} from "./timelineTrackVisibility";
|
|
30
30
|
|
|
31
|
+
interface AudioGroupCarveInput extends UseTimelineElementVisibilityEditingInput {
|
|
32
|
+
checkEditable?: (elements: readonly TimelineElement[]) => boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertAudioGroupEditable(
|
|
36
|
+
checkEditable: AudioGroupCarveInput["checkEditable"],
|
|
37
|
+
elements: readonly TimelineElement[],
|
|
38
|
+
): void {
|
|
39
|
+
if (checkEditable && !checkEditable(elements)) {
|
|
40
|
+
throw new Error("Timeline edit blocked");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
/**
|
|
32
45
|
* Assign (or restore) `data-audio-group` across a set of members.
|
|
33
46
|
*
|
|
@@ -267,7 +280,8 @@ export function useAudioGroupCarveAssignment({
|
|
|
267
280
|
previewIframeRef,
|
|
268
281
|
pendingTimelineEditPathRef,
|
|
269
282
|
isRecordingRef,
|
|
270
|
-
|
|
283
|
+
checkEditable,
|
|
284
|
+
}: AudioGroupCarveInput): (
|
|
271
285
|
clipIds: readonly string[],
|
|
272
286
|
groupId: string,
|
|
273
287
|
groupLabel?: string,
|
|
@@ -289,6 +303,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
289
303
|
const domId = runtimeAudioId(item);
|
|
290
304
|
return domId !== null && wanted.has(domId);
|
|
291
305
|
});
|
|
306
|
+
if (elements.length === wanted.size) assertAudioGroupEditable(checkEditable, elements);
|
|
292
307
|
try {
|
|
293
308
|
// Loud, not silent: an unresolved id used to leave `elements` short,
|
|
294
309
|
// `createAudioGroupAndAssignMembers` returning early with no write, and
|
|
@@ -333,6 +348,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
333
348
|
isRecordingRef,
|
|
334
349
|
showToast,
|
|
335
350
|
projectIdRef,
|
|
351
|
+
checkEditable,
|
|
336
352
|
],
|
|
337
353
|
);
|
|
338
354
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
2
|
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
3
3
|
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
4
4
|
import { usePlayerStore } from "../player";
|
|
@@ -29,6 +29,10 @@ function patchLiveGroupAttribute(
|
|
|
29
29
|
invalidateGroupInfoCache(iframe?.contentDocument);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function audioGroupAttributeLiveKey(groupId: string, attr: string): string {
|
|
33
|
+
return `${groupId}\0${attr}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
/**
|
|
33
37
|
* `data-volume` exactly as core reads it (`readAudioGroupVolume`): an absent or
|
|
34
38
|
* empty attribute is UNITY, not zero.
|
|
@@ -210,9 +214,16 @@ export function useSetAudioGroupAttribute({
|
|
|
210
214
|
}: UseTimelineElementVisibilityEditingInput): {
|
|
211
215
|
setLive: (groupId: string, attr: string, value: string | null) => void;
|
|
212
216
|
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
|
|
217
|
+
revertLive: (groupId: string, attr: string) => void;
|
|
213
218
|
} {
|
|
219
|
+
const liveBeforeRef = useRef(new Map<string, string | null>());
|
|
214
220
|
const setLive = useCallback(
|
|
215
221
|
(groupId: string, attr: string, value: string | null) => {
|
|
222
|
+
const key = audioGroupAttributeLiveKey(groupId, attr);
|
|
223
|
+
const target = previewIframeRef.current?.contentDocument?.getElementById(groupId);
|
|
224
|
+
if (!liveBeforeRef.current.has(key)) {
|
|
225
|
+
liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
|
|
226
|
+
}
|
|
216
227
|
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
|
|
217
228
|
// Live too, not just on commit: a fader drag is `setLive` per frame and
|
|
218
229
|
// `setQuiet` once on release, so without this the strip's own readout
|
|
@@ -221,6 +232,26 @@ export function useSetAudioGroupAttribute({
|
|
|
221
232
|
},
|
|
222
233
|
[previewIframeRef],
|
|
223
234
|
);
|
|
235
|
+
const revertLive = useCallback(
|
|
236
|
+
(groupId: string, attr: string) => {
|
|
237
|
+
const key = audioGroupAttributeLiveKey(groupId, attr);
|
|
238
|
+
if (!liveBeforeRef.current.has(key)) return;
|
|
239
|
+
patchLiveGroupAttribute(
|
|
240
|
+
previewIframeRef.current,
|
|
241
|
+
groupId,
|
|
242
|
+
attr,
|
|
243
|
+
liveBeforeRef.current.get(key) ?? null,
|
|
244
|
+
);
|
|
245
|
+
syncStoredGroupAttribute(
|
|
246
|
+
groupId,
|
|
247
|
+
attr,
|
|
248
|
+
previewIframeRef.current?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ??
|
|
249
|
+
null,
|
|
250
|
+
);
|
|
251
|
+
liveBeforeRef.current.delete(key);
|
|
252
|
+
},
|
|
253
|
+
[previewIframeRef],
|
|
254
|
+
);
|
|
224
255
|
const setQuiet = useCallback(
|
|
225
256
|
async (groupId: string, attr: string, value: string | null, label: string) => {
|
|
226
257
|
if (isRecordingRef?.current) {
|
|
@@ -242,6 +273,7 @@ export function useSetAudioGroupAttribute({
|
|
|
242
273
|
recordEdit,
|
|
243
274
|
pendingTimelineEditPathRef,
|
|
244
275
|
});
|
|
276
|
+
liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
|
|
245
277
|
syncStoredGroupAttribute(groupId, attr, value);
|
|
246
278
|
} catch (error) {
|
|
247
279
|
// `persistElementAttribute` leaves the live DOM at the previous value
|
|
@@ -257,6 +289,7 @@ export function useSetAudioGroupAttribute({
|
|
|
257
289
|
console.error("[Timeline] Failed to set group attribute", error);
|
|
258
290
|
const message = error instanceof Error ? error.message : "Failed to update group";
|
|
259
291
|
showToast(message);
|
|
292
|
+
liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
|
|
260
293
|
}
|
|
261
294
|
},
|
|
262
295
|
[
|
|
@@ -270,5 +303,5 @@ export function useSetAudioGroupAttribute({
|
|
|
270
303
|
projectIdRef,
|
|
271
304
|
],
|
|
272
305
|
);
|
|
273
|
-
return { setLive, setQuiet };
|
|
306
|
+
return { setLive, setQuiet, revertLive };
|
|
274
307
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
|
|
4
|
+
/** A host's verdict on one element: editable, or blocked with a reason to show. */
|
|
5
|
+
export type TimelineEditPermission = true | { blocked: true; reason: string };
|
|
6
|
+
|
|
7
|
+
export type CanEditTimelineElement = (element: TimelineElement) => TimelineEditPermission;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Refuses a write when any target element is blocked, toasting the host's
|
|
11
|
+
* reason. Absent `canEdit` never refuses, so Studio itself is unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export function useTimelineEditGate(
|
|
14
|
+
canEdit: CanEditTimelineElement | undefined,
|
|
15
|
+
showToast: (message: string, tone?: "error" | "info") => void,
|
|
16
|
+
) {
|
|
17
|
+
return useCallback(
|
|
18
|
+
(targets: readonly TimelineElement[]): boolean => {
|
|
19
|
+
if (!canEdit) return true;
|
|
20
|
+
for (const element of targets) {
|
|
21
|
+
const verdict = canEdit(element);
|
|
22
|
+
if (verdict !== true) {
|
|
23
|
+
showToast(verdict.reason, "error");
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
},
|
|
29
|
+
[canEdit, showToast],
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* shared core `setAudioGroupAttribute` also uses.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { useCallback } from "react";
|
|
10
|
+
import { useCallback, useRef } from "react";
|
|
11
11
|
import type { TimelineElement } from "../player";
|
|
12
12
|
import {
|
|
13
13
|
buildPatchTarget,
|
|
@@ -32,6 +32,14 @@ function patchLiveElementAttribute(
|
|
|
32
32
|
else target.setAttribute(attr, value);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function elementAttributeLiveKey(
|
|
36
|
+
element: TimelineElement,
|
|
37
|
+
activeCompPath: string | null,
|
|
38
|
+
attr: string,
|
|
39
|
+
): string {
|
|
40
|
+
return `${element.sourceFile || activeCompPath || "index.html"}\0${element.key ?? element.domId ?? element.id}\0${attr}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
interface SetElementAttributeInput {
|
|
36
44
|
projectId: string;
|
|
37
45
|
activeCompPath: string | null;
|
|
@@ -92,13 +100,35 @@ export function useSetElementAttribute({
|
|
|
92
100
|
value: string | null,
|
|
93
101
|
label: string,
|
|
94
102
|
) => Promise<void>;
|
|
103
|
+
revertLive: (element: TimelineElement, attr: string) => void;
|
|
95
104
|
} {
|
|
105
|
+
const liveBeforeRef = useRef(new Map<string, string | null>());
|
|
96
106
|
const setLive = useCallback(
|
|
97
107
|
(element: TimelineElement, attr: string, value: string | null) => {
|
|
108
|
+
const key = elementAttributeLiveKey(element, activeCompPath, attr);
|
|
109
|
+
const target = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
|
|
110
|
+
if (!liveBeforeRef.current.has(key)) {
|
|
111
|
+
liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
|
|
112
|
+
}
|
|
98
113
|
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
|
|
99
114
|
},
|
|
100
115
|
[previewIframeRef, activeCompPath],
|
|
101
116
|
);
|
|
117
|
+
const revertLive = useCallback(
|
|
118
|
+
(element: TimelineElement, attr: string) => {
|
|
119
|
+
const key = elementAttributeLiveKey(element, activeCompPath, attr);
|
|
120
|
+
if (!liveBeforeRef.current.has(key)) return;
|
|
121
|
+
patchLiveElementAttribute(
|
|
122
|
+
previewIframeRef.current,
|
|
123
|
+
element,
|
|
124
|
+
attr,
|
|
125
|
+
liveBeforeRef.current.get(key) ?? null,
|
|
126
|
+
activeCompPath,
|
|
127
|
+
);
|
|
128
|
+
liveBeforeRef.current.delete(key);
|
|
129
|
+
},
|
|
130
|
+
[previewIframeRef, activeCompPath],
|
|
131
|
+
);
|
|
102
132
|
const setQuiet = useCallback(
|
|
103
133
|
async (element: TimelineElement, attr: string, value: string | null, label: string) => {
|
|
104
134
|
if (isRecordingRef?.current) {
|
|
@@ -120,10 +150,12 @@ export function useSetElementAttribute({
|
|
|
120
150
|
recordEdit,
|
|
121
151
|
pendingTimelineEditPathRef,
|
|
122
152
|
});
|
|
153
|
+
liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
|
|
123
154
|
} catch (error) {
|
|
124
155
|
console.error("[Timeline] Failed to set element attribute", error);
|
|
125
156
|
const message = error instanceof Error ? error.message : "Failed to update effect";
|
|
126
157
|
showToast(message);
|
|
158
|
+
liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
|
|
127
159
|
}
|
|
128
160
|
},
|
|
129
161
|
[
|
|
@@ -137,5 +169,5 @@ export function useSetElementAttribute({
|
|
|
137
169
|
projectIdRef,
|
|
138
170
|
],
|
|
139
171
|
);
|
|
140
|
-
return { setLive, setQuiet };
|
|
172
|
+
return { setLive, setQuiet, revertLive };
|
|
141
173
|
}
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import { useState, useCallback,
|
|
1
|
+
import { useState, useCallback, 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 {
|
|
8
|
-
createStudioSaveHttpError,
|
|
9
|
-
retryStudioSave,
|
|
10
|
-
StudioFileConflictError,
|
|
11
|
-
StudioSaveNetworkError,
|
|
12
|
-
} from "../utils/studioSaveDiagnostics";
|
|
13
|
-
import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
|
7
|
+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
14
8
|
import { useFileTree } from "./useFileTree";
|
|
15
9
|
import { useEditorSave } from "./useEditorSave";
|
|
10
|
+
import { useProjectFileWriter } from "./useProjectFileWriter";
|
|
16
11
|
|
|
17
12
|
// ── Types ──
|
|
18
13
|
|
|
@@ -50,17 +45,13 @@ export function useFileManager({
|
|
|
50
45
|
projectIdRef.current = projectId;
|
|
51
46
|
|
|
52
47
|
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
fileVersions.set(path, version);
|
|
61
|
-
},
|
|
62
|
-
[fileVersions],
|
|
63
|
-
);
|
|
48
|
+
const {
|
|
49
|
+
fileVersions,
|
|
50
|
+
readProjectFile,
|
|
51
|
+
writeProjectFile: writeProjectFileRaw,
|
|
52
|
+
readOptionalProjectFile,
|
|
53
|
+
observeProjectFileVersion,
|
|
54
|
+
} = useProjectFileWriter({ projectId });
|
|
64
55
|
|
|
65
56
|
// ── File tree ──
|
|
66
57
|
|
|
@@ -74,117 +65,24 @@ export function useFileManager({
|
|
|
74
65
|
fontAssets,
|
|
75
66
|
} = useFileTree({ projectId, projectIdRef });
|
|
76
67
|
|
|
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
|
-
);
|
|
93
|
-
|
|
94
|
-
const writeProjectFile = useCallback(
|
|
95
|
-
async (path: string, content: string, expectedContent?: string): Promise<void> => {
|
|
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
|
-
});
|
|
163
|
-
if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
|
|
164
|
-
setEditingFile({ path, content });
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
[fileVersions, projectId],
|
|
168
|
-
);
|
|
169
|
-
|
|
170
68
|
const updateEditingFileContent = useCallback((path: string, content: string) => {
|
|
171
69
|
if (editingPathRef.current === path) {
|
|
172
70
|
setEditingFile({ path, content });
|
|
173
71
|
}
|
|
174
72
|
}, []);
|
|
175
73
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
const writeProjectFile = useCallback(
|
|
78
|
+
async (path: string, content: string, expectedContent?: string): Promise<void> => {
|
|
79
|
+
const writeProjectId = projectIdRef.current;
|
|
80
|
+
await writeProjectFileRaw(path, content, expectedContent);
|
|
81
|
+
if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
|
|
82
|
+
setEditingFile({ path, content });
|
|
83
|
+
}
|
|
186
84
|
},
|
|
187
|
-
[
|
|
85
|
+
[writeProjectFileRaw],
|
|
188
86
|
);
|
|
189
87
|
|
|
190
88
|
// ── 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
|
-
interface UsePersistentEditHistoryOptions {
|
|
36
|
+
export interface UsePersistentEditHistoryOptions {
|
|
37
37
|
projectId: string | null;
|
|
38
38
|
storage?: EditHistoryStorageAdapter;
|
|
39
39
|
now?: () => number;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createStudioSaveHttpError,
|
|
4
|
+
retryStudioSave,
|
|
5
|
+
StudioFileConflictError,
|
|
6
|
+
StudioSaveNetworkError,
|
|
7
|
+
} from "../utils/studioSaveDiagnostics";
|
|
8
|
+
import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
|
9
|
+
|
|
10
|
+
export interface UseProjectFileWriterOptions {
|
|
11
|
+
projectId: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The etag-guarded project-file read/write pair every Studio save path
|
|
15
|
+
// shares. A host mounting hand editing outside Studio needs only this, not
|
|
16
|
+
// the full file manager (file tree, editor tabs, uploads, font assets).
|
|
17
|
+
export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions) {
|
|
18
|
+
const fileVersionScope = useMemo(
|
|
19
|
+
() => ({ projectId, versions: new Map<string, string | null>() }),
|
|
20
|
+
[projectId],
|
|
21
|
+
);
|
|
22
|
+
const fileVersions = fileVersionScope.versions;
|
|
23
|
+
|
|
24
|
+
const observeProjectFileVersion = useCallback(
|
|
25
|
+
(path: string, version: string | null) => {
|
|
26
|
+
fileVersions.set(path, version);
|
|
27
|
+
},
|
|
28
|
+
[fileVersions],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const readProjectFile = useCallback(
|
|
32
|
+
async (path: string): Promise<string> => {
|
|
33
|
+
if (!projectId) throw new Error("No active project");
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
|
|
36
|
+
);
|
|
37
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
38
|
+
const data = (await response.json()) as { content?: string; version?: string };
|
|
39
|
+
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
40
|
+
fileVersions.set(path, data.version ?? response.headers.get("etag"));
|
|
41
|
+
return data.content;
|
|
42
|
+
},
|
|
43
|
+
[fileVersions, projectId],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const readOptionalProjectFile = useCallback(
|
|
47
|
+
async (path: string): Promise<string> => {
|
|
48
|
+
if (!projectId) throw new Error("No active project");
|
|
49
|
+
const response = await fetch(
|
|
50
|
+
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
51
|
+
);
|
|
52
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
53
|
+
const data = (await response.json()) as { content?: string; version?: string };
|
|
54
|
+
fileVersions.set(path, data.version ?? response.headers.get("etag"));
|
|
55
|
+
return typeof data.content === "string" ? data.content : "";
|
|
56
|
+
},
|
|
57
|
+
[fileVersions, projectId],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const writeProjectFile = useCallback(
|
|
61
|
+
async (path: string, content: string, expectedContent?: string): Promise<void> => {
|
|
62
|
+
if (!projectId) throw new Error("No active project");
|
|
63
|
+
const writeProjectId = projectId;
|
|
64
|
+
let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
|
|
65
|
+
if (expectedVersion === undefined) {
|
|
66
|
+
const preflight = await fetch(
|
|
67
|
+
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
|
68
|
+
);
|
|
69
|
+
if (preflight.ok) {
|
|
70
|
+
const data = (await preflight.json()) as { content?: string; version?: string };
|
|
71
|
+
throw new StudioFileConflictError({
|
|
72
|
+
filePath: path,
|
|
73
|
+
currentVersion: data.version ?? preflight.headers.get("etag"),
|
|
74
|
+
currentContent: data.content ?? null,
|
|
75
|
+
attemptedContent: content,
|
|
76
|
+
});
|
|
77
|
+
} else if (preflight.status === 404) {
|
|
78
|
+
expectedVersion = null;
|
|
79
|
+
} else {
|
|
80
|
+
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// fallow-ignore-next-line complexity
|
|
84
|
+
await retryStudioSave(async () => {
|
|
85
|
+
// Each request gets its own receipt identity. If a committed request loses its response,
|
|
86
|
+
// the retry can produce a second filesystem receipt that must be suppressed independently.
|
|
87
|
+
let response: Response;
|
|
88
|
+
try {
|
|
89
|
+
response = await fetch(
|
|
90
|
+
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
|
91
|
+
{
|
|
92
|
+
method: "PUT",
|
|
93
|
+
headers: {
|
|
94
|
+
"Content-Type": "text/plain",
|
|
95
|
+
...studioWriteHeaders(),
|
|
96
|
+
...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
|
|
97
|
+
},
|
|
98
|
+
body: content,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
|
|
103
|
+
cause: error,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (response.status === 409) {
|
|
107
|
+
const conflict = (await response.json().catch(() => null)) as {
|
|
108
|
+
currentVersion?: string | null;
|
|
109
|
+
currentContent?: string | null;
|
|
110
|
+
} | null;
|
|
111
|
+
const currentVersion = conflict?.currentVersion ?? null;
|
|
112
|
+
if (currentVersion && conflict?.currentContent === content) {
|
|
113
|
+
fileVersions.set(path, currentVersion);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
throw new StudioFileConflictError({
|
|
117
|
+
filePath: path,
|
|
118
|
+
currentVersion,
|
|
119
|
+
currentContent: conflict?.currentContent ?? null,
|
|
120
|
+
attemptedContent: content,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
|
|
124
|
+
const result = (await response.json()) as { version?: string };
|
|
125
|
+
const version = result.version ?? response.headers.get("etag");
|
|
126
|
+
if (!version)
|
|
127
|
+
throw new Error(`Save response for ${path} did not include a content version`);
|
|
128
|
+
fileVersions.set(path, version);
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
[fileVersions, projectId],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return useMemo(
|
|
135
|
+
() => ({
|
|
136
|
+
fileVersions,
|
|
137
|
+
readProjectFile,
|
|
138
|
+
writeProjectFile,
|
|
139
|
+
readOptionalProjectFile,
|
|
140
|
+
observeProjectFileVersion,
|
|
141
|
+
}),
|
|
142
|
+
[
|
|
143
|
+
fileVersions,
|
|
144
|
+
readProjectFile,
|
|
145
|
+
writeProjectFile,
|
|
146
|
+
readOptionalProjectFile,
|
|
147
|
+
observeProjectFileVersion,
|
|
148
|
+
],
|
|
149
|
+
);
|
|
150
|
+
}
|