@hyperframes/studio 0.8.81 → 0.8.83
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-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -5,11 +5,7 @@ import { FONT_EXT } from "../utils/mediaTypes";
|
|
|
5
5
|
|
|
6
6
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
7
7
|
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
|
8
|
-
import {
|
|
9
|
-
createStudioSaveHttpError,
|
|
10
|
-
StudioSaveHttpError,
|
|
11
|
-
trackStudioSaveFailure,
|
|
12
|
-
} from "../utils/studioSaveDiagnostics";
|
|
8
|
+
import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
13
9
|
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
|
14
10
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
15
11
|
import type { CommitDomEditPatchBatches, PersistDomEditOperations } from "./domEditCommitTypes";
|
|
@@ -26,15 +22,15 @@ import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
|
26
22
|
import {
|
|
27
23
|
AtomicElementPatchConvergenceError,
|
|
28
24
|
batchesAreInlineStyleOnly,
|
|
29
|
-
formatPatchRejectionMessage,
|
|
30
25
|
formatUnsafeFieldList,
|
|
31
|
-
getErrorDetail,
|
|
32
26
|
patchElementBatches,
|
|
33
|
-
|
|
27
|
+
postPatchElement,
|
|
28
|
+
writePreparedContent,
|
|
34
29
|
} from "./useDomEditCommitsHelpers";
|
|
35
30
|
import type { CutoverResult } from "../utils/sdkCutover";
|
|
36
|
-
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
37
31
|
import { reseekPreviewRuntime } from "./timelineTrackVisibility";
|
|
32
|
+
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
33
|
+
import { readProjectFileContent } from "../utils/studioFileHistory";
|
|
38
34
|
interface RecordEditInput {
|
|
39
35
|
label: string;
|
|
40
36
|
coalesceKey?: string;
|
|
@@ -159,23 +155,13 @@ export function useDomEditCommits({
|
|
|
159
155
|
return result;
|
|
160
156
|
};
|
|
161
157
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const originalContent = readData.content;
|
|
170
|
-
if (typeof originalContent !== "string") {
|
|
171
|
-
throw new Error(`Missing file contents for ${targetPath}`);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (projectIdRef.current !== expectedProjectId) {
|
|
175
|
-
throw new Error("Active project changed before the edit could be saved");
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (options?.shouldSave && !options.shouldSave()) return;
|
|
158
|
+
const readTarget = async (): Promise<string | null> => {
|
|
159
|
+
const content = await readProjectFileContent(pid, targetPath);
|
|
160
|
+
if (projectIdRef.current !== expectedProjectId) {
|
|
161
|
+
throw new Error("Active project changed before the edit could be saved");
|
|
162
|
+
}
|
|
163
|
+
return options?.shouldSave && !options.shouldSave() ? null : content;
|
|
164
|
+
};
|
|
179
165
|
|
|
180
166
|
// Validate layout values BEFORE any persist path runs. The SDK cutover
|
|
181
167
|
// path (onTrySdkPersist) returns early on success, so leaving this check
|
|
@@ -195,7 +181,10 @@ export function useDomEditCommits({
|
|
|
195
181
|
// Skip the SDK path when prepareContent is set (e.g. @font-face injection
|
|
196
182
|
// for a custom font): sdkCutoverPersist serializes only the patched DOM
|
|
197
183
|
// and would drop the injected content. Let the server path run prepareContent.
|
|
184
|
+
// The SDK joins the file queue itself and re-reads there; this read is only its fallback.
|
|
198
185
|
if (onTrySdkPersist && !options?.prepareContent) {
|
|
186
|
+
const originalContent = await readTarget();
|
|
187
|
+
if (originalContent === null) return;
|
|
199
188
|
const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
|
|
200
189
|
label: options?.label,
|
|
201
190
|
coalesceKey: options?.coalesceKey,
|
|
@@ -212,31 +201,41 @@ export function useDomEditCommits({
|
|
|
212
201
|
}
|
|
213
202
|
}
|
|
214
203
|
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
220
|
-
body: JSON.stringify(patchBody),
|
|
221
|
-
},
|
|
222
|
-
);
|
|
223
|
-
if (!patchResponse.ok) {
|
|
224
|
-
showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error");
|
|
225
|
-
throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`, {
|
|
226
|
-
alreadyToasted: true,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const patchData = (await patchResponse.json()) as {
|
|
231
|
-
ok?: boolean;
|
|
232
|
-
changed?: boolean;
|
|
233
|
-
matched?: boolean;
|
|
234
|
-
content?: string;
|
|
235
|
-
path?: string;
|
|
236
|
-
version?: string;
|
|
204
|
+
const history = {
|
|
205
|
+
label: options?.label ?? "Edit layer",
|
|
206
|
+
coalesceKey: options?.coalesceKey,
|
|
207
|
+
coalesceMs: options?.coalesceMs,
|
|
237
208
|
};
|
|
209
|
+
const prepare = options?.prepareContent;
|
|
210
|
+
// Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
|
|
211
|
+
const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
|
|
212
|
+
const originalContent = await readTarget();
|
|
213
|
+
if (originalContent === null) return null;
|
|
214
|
+
const patchData = await postPatchElement(pid, targetPath, patchBody, showToast);
|
|
215
|
+
if (!patchData.changed) return { patchData, patchedContent: null, finalContent: null };
|
|
216
|
+
|
|
217
|
+
const patchedContent =
|
|
218
|
+
typeof patchData.content === "string" ? patchData.content : originalContent;
|
|
219
|
+
const finalContent = prepare
|
|
220
|
+
? await writePreparedContent(
|
|
221
|
+
targetPath,
|
|
222
|
+
patchedContent,
|
|
223
|
+
prepare,
|
|
224
|
+
writeProjectFile,
|
|
225
|
+
showToast,
|
|
226
|
+
)
|
|
227
|
+
: patchedContent;
|
|
228
|
+
|
|
229
|
+
await editHistory.recordEdit({
|
|
230
|
+
...history,
|
|
231
|
+
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
232
|
+
});
|
|
233
|
+
return { patchData, patchedContent, finalContent };
|
|
234
|
+
});
|
|
235
|
+
if (saved === null) return;
|
|
236
|
+
const { patchData, patchedContent, finalContent } = saved;
|
|
238
237
|
|
|
239
|
-
if (
|
|
238
|
+
if (finalContent === null) {
|
|
240
239
|
if (patchData.matched === false) {
|
|
241
240
|
const targetKey = selection.selector ?? selection.id ?? "selection";
|
|
242
241
|
if (!reportedUnresolvableRef.current.has(targetKey)) {
|
|
@@ -258,36 +257,6 @@ export function useDomEditCommits({
|
|
|
258
257
|
false,
|
|
259
258
|
);
|
|
260
259
|
}
|
|
261
|
-
|
|
262
|
-
const patchedContent =
|
|
263
|
-
typeof patchData.content === "string" ? patchData.content : originalContent;
|
|
264
|
-
|
|
265
|
-
let finalContent = patchedContent;
|
|
266
|
-
if (options?.prepareContent) {
|
|
267
|
-
const preparedContent = options.prepareContent(patchedContent, targetPath);
|
|
268
|
-
if (preparedContent !== patchedContent) {
|
|
269
|
-
try {
|
|
270
|
-
await writeProjectFile(targetPath, preparedContent, patchedContent);
|
|
271
|
-
finalContent = preparedContent;
|
|
272
|
-
} catch (error) {
|
|
273
|
-
// The patch above already landed on disk — only the prepareContent
|
|
274
|
-
// embellishment (e.g. an injected @font-face) failed to write. Keep
|
|
275
|
-
// the already-persisted patchedContent instead of throwing, which
|
|
276
|
-
// would otherwise revert a change the server already committed.
|
|
277
|
-
showToast(
|
|
278
|
-
`Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
|
|
279
|
-
"error",
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
await editHistory.recordEdit({
|
|
286
|
-
label: options?.label ?? "Edit layer",
|
|
287
|
-
coalesceKey: options?.coalesceKey,
|
|
288
|
-
coalesceMs: options?.coalesceMs,
|
|
289
|
-
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
290
|
-
});
|
|
291
260
|
forceReloadSdkSession?.();
|
|
292
261
|
|
|
293
262
|
if (!options?.skipRefresh) {
|
|
@@ -350,24 +319,38 @@ export function useDomEditCommits({
|
|
|
350
319
|
);
|
|
351
320
|
}
|
|
352
321
|
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
322
|
+
const sourceFiles = batches.map((batch) => batch.sourceFile);
|
|
323
|
+
// The server patch and its history entry hold every touched file's queue.
|
|
324
|
+
const { allMatched, changed } = await serializeStudioFileMutations(
|
|
325
|
+
writeProjectFile,
|
|
326
|
+
sourceFiles,
|
|
327
|
+
async () => {
|
|
328
|
+
const atomicResult = await patchElementBatches(pid, batches);
|
|
329
|
+
const files = Object.fromEntries(
|
|
330
|
+
atomicResult.files
|
|
331
|
+
.filter((result) => result.changed)
|
|
332
|
+
.map((result) => [
|
|
333
|
+
result.sourceFile,
|
|
334
|
+
{ before: result.before, after: result.after },
|
|
335
|
+
]),
|
|
336
|
+
);
|
|
337
|
+
const anyChanged = Object.keys(files).length > 0;
|
|
338
|
+
if (anyChanged) {
|
|
339
|
+
await editHistory.recordEdit({
|
|
340
|
+
label: options.label,
|
|
341
|
+
coalesceKey: options.coalesceKey,
|
|
342
|
+
coalesceMs: options.coalesceMs,
|
|
343
|
+
files,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
allMatched:
|
|
348
|
+
atomicResult.durable && atomicResult.files.every((result) => result.allMatched),
|
|
349
|
+
changed: anyChanged,
|
|
350
|
+
};
|
|
351
|
+
},
|
|
360
352
|
);
|
|
361
|
-
|
|
362
|
-
if (changed) {
|
|
363
|
-
await editHistory.recordEdit({
|
|
364
|
-
label: options.label,
|
|
365
|
-
coalesceKey: options.coalesceKey,
|
|
366
|
-
coalesceMs: options.coalesceMs,
|
|
367
|
-
files,
|
|
368
|
-
});
|
|
369
|
-
forceReloadSdkSession?.();
|
|
370
|
-
}
|
|
353
|
+
if (changed) forceReloadSdkSession?.();
|
|
371
354
|
const durable = allMatched;
|
|
372
355
|
// A z-only reorder already applied its inline styles to the live iframe
|
|
373
356
|
// DOM (and the store) synchronously, so remounting the iframe here only
|
|
@@ -399,7 +382,15 @@ export function useDomEditCommits({
|
|
|
399
382
|
throw error;
|
|
400
383
|
});
|
|
401
384
|
},
|
|
402
|
-
[
|
|
385
|
+
[
|
|
386
|
+
editHistory,
|
|
387
|
+
forceReloadSdkSession,
|
|
388
|
+
projectIdRef,
|
|
389
|
+
queueDomEditSave,
|
|
390
|
+
reloadPreview,
|
|
391
|
+
showToast,
|
|
392
|
+
writeProjectFile,
|
|
393
|
+
],
|
|
403
394
|
);
|
|
404
395
|
|
|
405
396
|
// ── Text & style commits (delegated to useDomEditTextCommits) ──
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createStudioSaveHttpError,
|
|
3
|
+
StudioSaveHttpError,
|
|
4
|
+
trackStudioSaveFailure,
|
|
5
|
+
} from "../utils/studioSaveDiagnostics";
|
|
6
|
+
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
7
|
import type { DomEditPatchBatch } from "./domEditCommitTypes";
|
|
3
8
|
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
|
4
9
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
@@ -7,11 +12,11 @@ export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
|
|
7
12
|
return fields.map((field) => field.path).join(", ");
|
|
8
13
|
}
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
function getErrorDetail(error: unknown): string {
|
|
11
16
|
return error instanceof Error ? error.message : String(error);
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
async function readErrorResponseBody(
|
|
15
20
|
response: Response,
|
|
16
21
|
): Promise<{ error?: string; fields?: string[] } | null> {
|
|
17
22
|
const contentType = response.headers.get("content-type") ?? "";
|
|
@@ -19,9 +24,7 @@ export async function readErrorResponseBody(
|
|
|
19
24
|
return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
body: { error?: string; fields?: string[] } | null,
|
|
24
|
-
): string {
|
|
27
|
+
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
|
|
25
28
|
if (!body?.error) return "Couldn't save edit";
|
|
26
29
|
return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
|
|
27
30
|
}
|
|
@@ -157,3 +160,63 @@ export function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean
|
|
|
157
160
|
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
|
|
158
161
|
);
|
|
159
162
|
}
|
|
163
|
+
|
|
164
|
+
export interface PatchElementResponse {
|
|
165
|
+
ok?: boolean;
|
|
166
|
+
changed?: boolean;
|
|
167
|
+
matched?: boolean;
|
|
168
|
+
content?: string;
|
|
169
|
+
path?: string;
|
|
170
|
+
version?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type ShowToast = (message: string, tone?: "error" | "info") => void;
|
|
174
|
+
|
|
175
|
+
export async function postPatchElement(
|
|
176
|
+
projectId: string,
|
|
177
|
+
targetPath: string,
|
|
178
|
+
body: unknown,
|
|
179
|
+
showToast: ShowToast,
|
|
180
|
+
): Promise<PatchElementResponse> {
|
|
181
|
+
const response = await fetch(
|
|
182
|
+
buildProjectApiPath(
|
|
183
|
+
projectId,
|
|
184
|
+
`/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
|
185
|
+
),
|
|
186
|
+
{
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
189
|
+
body: JSON.stringify(body),
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
showToast(formatPatchRejectionMessage(await readErrorResponseBody(response)), "error");
|
|
194
|
+
throw await createStudioSaveHttpError(response, `Failed to patch ${targetPath}`, {
|
|
195
|
+
alreadyToasted: true,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return (await response.json()) as PatchElementResponse;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Writes `prepare`'s embellishment over the server's patch; returns what the file ends up holding. */
|
|
202
|
+
export async function writePreparedContent(
|
|
203
|
+
targetPath: string,
|
|
204
|
+
patchedContent: string,
|
|
205
|
+
prepare: (html: string, sourceFile: string) => string,
|
|
206
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
207
|
+
showToast: ShowToast,
|
|
208
|
+
): Promise<string> {
|
|
209
|
+
const preparedContent = prepare(patchedContent, targetPath);
|
|
210
|
+
if (preparedContent === patchedContent) return patchedContent;
|
|
211
|
+
try {
|
|
212
|
+
await writeProjectFile(targetPath, preparedContent, patchedContent);
|
|
213
|
+
return preparedContent;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// The patch already landed on disk; keep it rather than revert a committed change.
|
|
216
|
+
showToast(
|
|
217
|
+
`Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
|
|
218
|
+
"error",
|
|
219
|
+
);
|
|
220
|
+
return patchedContent;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -107,7 +107,7 @@ export function useEditorSave({
|
|
|
107
107
|
projectId: candidate.projectId,
|
|
108
108
|
label: "Edit source",
|
|
109
109
|
coalesceKey: `source:${candidate.path}`,
|
|
110
|
-
files: { [candidate.path]: candidate.content },
|
|
110
|
+
files: { [candidate.path]: () => candidate.content },
|
|
111
111
|
readFile: readProjectFile,
|
|
112
112
|
writeFile: writeProjectFile,
|
|
113
113
|
recordEdit,
|
|
@@ -3,7 +3,7 @@ import { useCallback } from "react";
|
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
4
|
import {
|
|
5
5
|
readProjectFileContent,
|
|
6
|
-
|
|
6
|
+
saveServerRewriteWithHistory,
|
|
7
7
|
type DomEditCommitBaseParams,
|
|
8
8
|
} from "../utils/studioFileHistory";
|
|
9
9
|
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
@@ -56,7 +56,7 @@ let zReorderGestureSeq = 0;
|
|
|
56
56
|
* fold can only ever merge records of the SAME gesture.
|
|
57
57
|
*
|
|
58
58
|
* Exported as THE single implementation of the key: the canvas z-order wiring
|
|
59
|
-
* (
|
|
59
|
+
* (ConnectedDomEditOverlay) mints it once per gesture and passes the same instance to
|
|
60
60
|
* both the z persist and the timeline lane mirror (useCanvasZOrderTimelineMirror)
|
|
61
61
|
* so editHistory folds the z write and the track write into one undo entry —
|
|
62
62
|
* recomputing the key per record would silently split the undo.
|
|
@@ -111,8 +111,6 @@ export function useElementLifecycleOps({
|
|
|
111
111
|
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
|
112
112
|
);
|
|
113
113
|
try {
|
|
114
|
-
const originalContent = await readProjectFileContent(pid, targetPath);
|
|
115
|
-
|
|
116
114
|
const patchTargets = sameFile.map((member) => buildDomEditPatchTarget(member));
|
|
117
115
|
if (patchTargets.some((t) => !t.id && !t.selector && !t.hfId)) {
|
|
118
116
|
throw new Error("Selected element has no patchable target");
|
|
@@ -125,6 +123,7 @@ export function useElementLifecycleOps({
|
|
|
125
123
|
.map((member) => member.hfId)
|
|
126
124
|
.filter((hfId): hfId is string => Boolean(hfId));
|
|
127
125
|
if (onTrySdkDelete && hfIds.length === sameFile.length) {
|
|
126
|
+
const originalContent = await readProjectFileContent(pid, targetPath);
|
|
128
127
|
let allHandled = true;
|
|
129
128
|
for (const hfId of hfIds) {
|
|
130
129
|
// The SDK owns the document it edits, so every member is removed
|
|
@@ -150,28 +149,41 @@ export function useElementLifecycleOps({
|
|
|
150
149
|
// cost a round trip and a rewrite of the file EACH, and a canvas
|
|
151
150
|
// selection runs to hundreds of members — the file ended up correct, but
|
|
152
151
|
// only after long enough that Delete looked like it had done nothing.
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
152
|
+
const deleted = await saveServerRewriteWithHistory({
|
|
153
|
+
projectId: pid,
|
|
154
|
+
path: targetPath,
|
|
155
|
+
label: "Delete element",
|
|
156
|
+
writeFile: writeProjectFile,
|
|
157
|
+
recordEdit: editHistory.recordEdit,
|
|
158
|
+
rewrite: async (originalContent) => {
|
|
159
|
+
const removeResponse = await fetch(
|
|
160
|
+
buildProjectApiPath(
|
|
161
|
+
pid,
|
|
162
|
+
`/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
|
|
163
|
+
),
|
|
164
|
+
{
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
167
|
+
body: JSON.stringify({ targets: patchTargets }),
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
if (!removeResponse.ok) {
|
|
171
|
+
throw await createStudioSaveHttpError(
|
|
172
|
+
removeResponse,
|
|
173
|
+
`Failed to delete element from ${targetPath}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const removeData = (await removeResponse.json()) as {
|
|
177
|
+
changed?: boolean;
|
|
178
|
+
content?: string;
|
|
179
|
+
};
|
|
180
|
+
if (!removeData.changed) return null;
|
|
181
|
+
return {
|
|
182
|
+
disk: typeof removeData.content === "string" ? removeData.content : originalContent,
|
|
183
|
+
};
|
|
162
184
|
},
|
|
163
|
-
);
|
|
164
|
-
if (!
|
|
165
|
-
throw await createStudioSaveHttpError(
|
|
166
|
-
removeResponse,
|
|
167
|
-
`Failed to delete element from ${targetPath}`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
const removeData = (await removeResponse.json()) as {
|
|
171
|
-
changed?: boolean;
|
|
172
|
-
content?: string;
|
|
173
|
-
};
|
|
174
|
-
if (!removeData.changed) {
|
|
185
|
+
});
|
|
186
|
+
if (!deleted) {
|
|
175
187
|
// A member the file no longer holds simply does not match, which is
|
|
176
188
|
// normal for one nested inside another member already removed. Nothing
|
|
177
189
|
// matching at all means the preview is describing a document the file
|
|
@@ -180,19 +192,6 @@ export function useElementLifecycleOps({
|
|
|
180
192
|
showToast("Nothing to delete, the preview was out of date. Try again.");
|
|
181
193
|
return domEditCommitDeclined("preview-stale");
|
|
182
194
|
}
|
|
183
|
-
const patchedContent =
|
|
184
|
-
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
185
|
-
await saveProjectFilesWithHistory({
|
|
186
|
-
projectId: pid,
|
|
187
|
-
label: "Delete element",
|
|
188
|
-
files: { [targetPath]: patchedContent },
|
|
189
|
-
readFile: async () => originalContent,
|
|
190
|
-
// remove-element already wrote the removal, so disk holds THAT — not
|
|
191
|
-
// the content read at the top. Undo still goes back to the original.
|
|
192
|
-
diskContent: { [targetPath]: patchedContent },
|
|
193
|
-
writeFile: writeProjectFile,
|
|
194
|
-
recordEdit: editHistory.recordEdit,
|
|
195
|
-
});
|
|
196
195
|
|
|
197
196
|
clearDomSelection();
|
|
198
197
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
@@ -31,19 +31,19 @@ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
|
31
31
|
|
|
32
32
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
33
33
|
|
|
34
|
-
function useTestFileManager(projectId: string) {
|
|
34
|
+
function useTestFileManager(projectId: string, showToast = vi.fn()) {
|
|
35
35
|
return useFileManager({
|
|
36
36
|
projectId,
|
|
37
|
-
showToast
|
|
37
|
+
showToast,
|
|
38
38
|
recordEdit: vi.fn(async () => {}),
|
|
39
39
|
setRefreshKey: vi.fn(),
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
async function mountTestFileManager(projectId = "project-a") {
|
|
43
|
+
async function mountTestFileManager(projectId = "project-a", showToast = vi.fn()) {
|
|
44
44
|
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
|
|
45
45
|
function Probe() {
|
|
46
|
-
captured.manager = useTestFileManager(projectId);
|
|
46
|
+
captured.manager = useTestFileManager(projectId, showToast);
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
49
49
|
const root = createRoot(document.createElement("div"));
|
|
@@ -237,3 +237,50 @@ describe("useFileManager project ownership", () => {
|
|
|
237
237
|
await act(async () => root.unmount());
|
|
238
238
|
});
|
|
239
239
|
});
|
|
240
|
+
|
|
241
|
+
describe("useFileManager uploads", () => {
|
|
242
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
243
|
+
|
|
244
|
+
async function uploadClip(response: Record<string, unknown>) {
|
|
245
|
+
vi.stubGlobal(
|
|
246
|
+
"fetch",
|
|
247
|
+
vi.fn(async () =>
|
|
248
|
+
Response.json(
|
|
249
|
+
{ ok: true, files: [], skipped: [], invalid: [], ...response },
|
|
250
|
+
{ status: 201 },
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
const showToast = vi.fn();
|
|
255
|
+
const { manager, root } = await mountTestFileManager("project-a", showToast);
|
|
256
|
+
const added = await act(() => manager.uploadProjectFiles([new File(["x"], "clip.mp4")]));
|
|
257
|
+
await act(async () => root.unmount());
|
|
258
|
+
return { added, showToast };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
it("says which uploads were added without a media check, and why", async () => {
|
|
262
|
+
const reason =
|
|
263
|
+
"not checked: ffprobe was not found. Install FFmpeg or set HYPERFRAMES_FFPROBE_PATH.";
|
|
264
|
+
const { added, showToast } = await uploadClip({
|
|
265
|
+
files: ["clip.mp4"],
|
|
266
|
+
unchecked: [{ name: "clip.mp4", reason }],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(added).toEqual(["clip.mp4"]);
|
|
270
|
+
expect(showToast).toHaveBeenCalledWith(`Added clip.mp4, ${reason}`, "info");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("says why each upload was not added", async () => {
|
|
274
|
+
const { added, showToast } = await uploadClip({
|
|
275
|
+
invalid: [
|
|
276
|
+
{ name: "clip.mp4", reason: "no supported video stream found" },
|
|
277
|
+
{ name: "song.mp3", reason: "no supported audio stream found" },
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(added).toEqual([]);
|
|
282
|
+
expect(showToast).toHaveBeenCalledWith(
|
|
283
|
+
"Not added: clip.mp4 (no supported video stream found), song.mp3 (no supported audio stream found)",
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -4,6 +4,7 @@ import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
|
|
|
4
4
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
5
|
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
|
|
6
6
|
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
7
|
+
import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
|
|
7
8
|
import { useFileTree } from "./useFileTree";
|
|
8
9
|
import { useEditorSave } from "./useEditorSave";
|
|
9
10
|
import { useProjectFileWriter } from "./useProjectFileWriter";
|
|
@@ -98,16 +99,18 @@ export function useFileManager({
|
|
|
98
99
|
|
|
99
100
|
const overwriteExternalConflict = useCallback(
|
|
100
101
|
async (conflict: StudioFileConflictError) => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
102
|
+
await serializeStudioFileMutation(writeProjectFile, conflict.filePath, async () => {
|
|
103
|
+
if (conflict.currentContent != null) {
|
|
104
|
+
await writeProjectFile(
|
|
105
|
+
conflict.filePath,
|
|
106
|
+
conflict.attemptedContent,
|
|
107
|
+
conflict.currentContent,
|
|
108
|
+
);
|
|
109
|
+
} else {
|
|
110
|
+
fileVersions.set(conflict.filePath, conflict.currentVersion);
|
|
111
|
+
await writeProjectFile(conflict.filePath, conflict.attemptedContent);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
111
114
|
updateEditingFileContent(conflict.filePath, conflict.attemptedContent);
|
|
112
115
|
},
|
|
113
116
|
[fileVersions, updateEditingFileContent, writeProjectFile],
|
|
@@ -207,8 +210,14 @@ export function useFileManager({
|
|
|
207
210
|
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
|
208
211
|
}
|
|
209
212
|
if (data.invalid?.length) {
|
|
210
|
-
const
|
|
211
|
-
|
|
213
|
+
const why = data.invalid
|
|
214
|
+
.map((entry: { name: string; reason: string }) => `${entry.name} (${entry.reason})`)
|
|
215
|
+
.join(", ");
|
|
216
|
+
showToast(`Not added: ${why}`);
|
|
217
|
+
}
|
|
218
|
+
if (data.unchecked?.length) {
|
|
219
|
+
const names = data.unchecked.map((entry: { name: string }) => entry.name).join(", ");
|
|
220
|
+
showToast(`Added ${names}, ${data.unchecked[0].reason}`, "info");
|
|
212
221
|
}
|
|
213
222
|
await refreshFileTree();
|
|
214
223
|
setRefreshKey((k) => k + 1);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
saveProjectFilesWithHistory,
|
|
4
|
+
saveServerRewriteWithHistory,
|
|
6
5
|
type DomEditCommitBaseParams,
|
|
7
6
|
} from "../utils/studioFileHistory";
|
|
8
7
|
import {
|
|
@@ -125,36 +124,36 @@ async function commitStructuralMutation(
|
|
|
125
124
|
| "reloadPreview"
|
|
126
125
|
>,
|
|
127
126
|
): Promise<{ content?: string; groupId?: string }> {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const mutateResponse = await fetch(
|
|
131
|
-
buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
|
|
132
|
-
{
|
|
133
|
-
method: "POST",
|
|
134
|
-
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
135
|
-
body: JSON.stringify(body),
|
|
136
|
-
},
|
|
137
|
-
);
|
|
138
|
-
if (!mutateResponse.ok) {
|
|
139
|
-
const errBody = (await mutateResponse.json().catch(() => null)) as { error?: string } | null;
|
|
140
|
-
throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`);
|
|
141
|
-
}
|
|
142
|
-
const mutateData = (await mutateResponse.json()) as { content?: string; groupId?: string };
|
|
143
|
-
const patchedContent =
|
|
144
|
-
typeof mutateData.content === "string" ? mutateData.content : originalContent;
|
|
145
|
-
|
|
146
|
-
await saveProjectFilesWithHistory({
|
|
127
|
+
let result: { content?: string; groupId?: string } = {};
|
|
128
|
+
await saveServerRewriteWithHistory({
|
|
147
129
|
projectId: pid,
|
|
130
|
+
path: targetPath,
|
|
148
131
|
label,
|
|
149
|
-
files: { [targetPath]: patchedContent },
|
|
150
|
-
readFile: async () => originalContent,
|
|
151
132
|
writeFile: deps.writeProjectFile,
|
|
152
133
|
recordEdit: deps.editHistory.recordEdit,
|
|
134
|
+
rewrite: async (originalContent) => {
|
|
135
|
+
const mutateResponse = await fetch(
|
|
136
|
+
buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
|
|
137
|
+
{
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
140
|
+
body: JSON.stringify(body),
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
if (!mutateResponse.ok) {
|
|
144
|
+
const errBody = (await mutateResponse.json().catch(() => null)) as {
|
|
145
|
+
error?: string;
|
|
146
|
+
} | null;
|
|
147
|
+
throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`);
|
|
148
|
+
}
|
|
149
|
+
result = (await mutateResponse.json()) as { content?: string; groupId?: string };
|
|
150
|
+
return { disk: typeof result.content === "string" ? result.content : originalContent };
|
|
151
|
+
},
|
|
153
152
|
});
|
|
154
153
|
deps.clearDomSelection();
|
|
155
154
|
deps.forceReloadSdkSession?.();
|
|
156
155
|
deps.reloadPreview();
|
|
157
|
-
return
|
|
156
|
+
return result;
|
|
158
157
|
}
|
|
159
158
|
|
|
160
159
|
export function useGroupCommits(params: UseGroupCommitsParams) {
|