@hyperframes/studio 0.8.80 → 0.8.82
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-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
- package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2567 -2314
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -9
- package/src/components/StudioRightPanels.tsx +4 -48
- 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/anchoredResizeReleaseShift.test.ts +2 -6
- 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/propertyPanelAudioFxGroup.test.tsx +71 -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/useFxLevelling.ts +35 -10
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
- package/src/components/panels/VariablesPanel.test.tsx +86 -0
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
- package/src/components/sidebar/SidebarLintButton.tsx +4 -0
- package/src/components/studioColorGradingScope.ts +16 -42
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- 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/useApplyColorGradingScope.test.tsx +53 -0
- package/src/hooks/useApplyColorGradingScope.ts +54 -0
- 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 +36 -37
- 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/useLintModal.test.tsx +15 -0
- package/src/hooks/useLintModal.ts +5 -2
- package/src/hooks/useProjectCompositionVariables.ts +3 -3
- 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 +8 -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/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- 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/useTimelinePlayhead.ts +26 -8
- 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/timelineChromeExports.test.tsx +79 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
7
|
+
import { useAskAgentModal } from "./useAskAgentModal";
|
|
8
|
+
|
|
9
|
+
const copied = vi.hoisted(() => [] as string[]);
|
|
10
|
+
vi.mock("../utils/clipboard", () => ({
|
|
11
|
+
copyTextToClipboard: async (text: string) => copied.push(text) > 0,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
document.body.innerHTML = "";
|
|
18
|
+
copied.length = 0;
|
|
19
|
+
vi.unstubAllGlobals();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("useAskAgentModal", () => {
|
|
23
|
+
it("sends the live element without the preview's lazy loading or look-ahead mark, keeping authored loading", async () => {
|
|
24
|
+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
|
25
|
+
const element = document.createElement("div");
|
|
26
|
+
element.setAttribute("data-hf-upcoming", "");
|
|
27
|
+
element.innerHTML =
|
|
28
|
+
'<img src="plate.png" loading="lazy" data-hf-preview-lazy><img src="own.png" loading="eager">';
|
|
29
|
+
const selection: DomEditSelection = {
|
|
30
|
+
element,
|
|
31
|
+
label: "Plate",
|
|
32
|
+
tagName: "div",
|
|
33
|
+
sourceFile: "index.html",
|
|
34
|
+
compositionPath: "index.html",
|
|
35
|
+
isCompositionHost: false,
|
|
36
|
+
isInsideLockedComposition: false,
|
|
37
|
+
selector: "div",
|
|
38
|
+
selectorIndex: 0,
|
|
39
|
+
boundingBox: { x: 0, y: 0, width: 10, height: 10 },
|
|
40
|
+
textContent: "",
|
|
41
|
+
dataAttributes: {},
|
|
42
|
+
inlineStyles: {},
|
|
43
|
+
computedStyles: {},
|
|
44
|
+
textFields: [],
|
|
45
|
+
capabilities: {
|
|
46
|
+
canSelect: true,
|
|
47
|
+
canEditStyles: true,
|
|
48
|
+
canCrop: false,
|
|
49
|
+
canMove: true,
|
|
50
|
+
canResize: true,
|
|
51
|
+
canApplyManualOffset: false,
|
|
52
|
+
canApplyManualSize: false,
|
|
53
|
+
canApplyManualRotation: false,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
let hook: ReturnType<typeof useAskAgentModal> | null = null;
|
|
57
|
+
function Harness() {
|
|
58
|
+
hook = useAskAgentModal({
|
|
59
|
+
projectId: "demo",
|
|
60
|
+
activeCompPath: "index.html",
|
|
61
|
+
projectDir: "/demo",
|
|
62
|
+
projectIdRef: { current: "demo" },
|
|
63
|
+
showToast: () => {},
|
|
64
|
+
domEditSelectionRef: { current: selection },
|
|
65
|
+
domEditSelection: selection,
|
|
66
|
+
});
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
70
|
+
await act(async () => createRoot(host).render(<Harness />));
|
|
71
|
+
|
|
72
|
+
await act(async () => hook!.handleAgentModalSubmit("Make it bigger"));
|
|
73
|
+
|
|
74
|
+
expect(copied[0]).toContain('<img src="plate.png"><img src="own.png" loading="eager">');
|
|
75
|
+
expect(copied[0]).not.toContain("data-hf-preview-lazy");
|
|
76
|
+
expect(copied[0]).not.toContain("data-hf-upcoming");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
|
|
4
4
|
import { copyTextToClipboard } from "../utils/clipboard";
|
|
5
5
|
import { readTagSnippetByTarget } from "../utils/sourcePatcher";
|
|
6
6
|
import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
|
|
7
|
+
import { liveMarkupWithoutPreviewMarks } from "../utils/authoredSource";
|
|
7
8
|
import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
|
|
8
9
|
import { usePlayerStore } from "../player";
|
|
9
10
|
|
|
@@ -89,7 +90,8 @@ export function useAskAgentModal({
|
|
|
89
90
|
if (!domEditSelection) return;
|
|
90
91
|
|
|
91
92
|
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
92
|
-
const tagSnippet =
|
|
93
|
+
const tagSnippet =
|
|
94
|
+
agentPromptTagSnippet ?? liveMarkupWithoutPreviewMarks(domEditSelection.element);
|
|
93
95
|
const prompt = buildElementAgentPrompt({
|
|
94
96
|
selection: domEditSelection,
|
|
95
97
|
currentTime: usePlayerStore.getState().currentTime,
|
|
@@ -220,6 +220,27 @@ describe("copy takes a clip's saved markup, not the runtime's live styling", ()
|
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
describe("copy of a clip missing from its saved file", () => {
|
|
224
|
+
it("takes the preview's markup without its lazy loading or look-ahead mark, keeping authored loading", async () => {
|
|
225
|
+
selectTitle();
|
|
226
|
+
const { clipboard, writes, files, iframe } = mountClipboard();
|
|
227
|
+
const title = iframe.contentDocument?.querySelector("h1") as Element;
|
|
228
|
+
title.setAttribute("data-hf-upcoming", "");
|
|
229
|
+
title.insertAdjacentHTML(
|
|
230
|
+
"beforeend",
|
|
231
|
+
'<img src="plate.png" loading="lazy" data-hf-preview-lazy><img src="own.png" loading="lazy">',
|
|
232
|
+
);
|
|
233
|
+
files["index.html"] = SAVED.replace(/<h1[\s\S]*<\/h1>/, "");
|
|
234
|
+
clipboard().handleCopy();
|
|
235
|
+
await clipboard().handlePaste();
|
|
236
|
+
expect(writes[0]).toMatch(
|
|
237
|
+
/<img src="plate.png" data-hf-id="[^"]+"><img src="own.png" loading="lazy"/,
|
|
238
|
+
);
|
|
239
|
+
expect(writes[0]).not.toContain("data-hf-preview-lazy");
|
|
240
|
+
expect(writes[0]).not.toContain("data-hf-upcoming");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
223
244
|
const SUB_SELECTION = {
|
|
224
245
|
hfId: SUB_HF_ID,
|
|
225
246
|
selector: "h2",
|
|
@@ -338,3 +359,14 @@ describe("a copy that fails", () => {
|
|
|
338
359
|
expect(deleted).toEqual([]);
|
|
339
360
|
});
|
|
340
361
|
});
|
|
362
|
+
|
|
363
|
+
describe("with no DOM edit session", () => {
|
|
364
|
+
it("refuses copy, cut and duplicate when nothing is selected", async () => {
|
|
365
|
+
clearSelection();
|
|
366
|
+
const { clipboard, deleted } = mountClipboard();
|
|
367
|
+
expect(clipboard().handleCopy()).toBe(false);
|
|
368
|
+
expect(await clipboard().handleCut()).toBe(false);
|
|
369
|
+
expect(await clipboard().handleDuplicate()).toBe(false);
|
|
370
|
+
expect(deleted).toEqual([]);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
authoredMarkup,
|
|
24
24
|
findAuthoredElement,
|
|
25
25
|
findAuthoredElementById,
|
|
26
|
+
liveMarkupWithoutPreviewMarks,
|
|
26
27
|
parseSavedSource,
|
|
27
28
|
} from "../utils/authoredSource";
|
|
28
29
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
@@ -33,7 +34,7 @@ interface RecordEditInput {
|
|
|
33
34
|
files: Record<string, { before: string; after: string }>;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
interface UseClipboardOptions {
|
|
37
|
+
export interface UseClipboardOptions {
|
|
37
38
|
projectId: string | null;
|
|
38
39
|
activeCompPath: string | null;
|
|
39
40
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
@@ -88,7 +89,9 @@ function getSelectedDomElement(
|
|
|
88
89
|
|
|
89
90
|
function savedMarkupElseLive(saved: Document, live: Element, sourceFile: string): string {
|
|
90
91
|
const authored = findAuthoredElement(saved, live) ?? findAuthoredElementById(saved, live);
|
|
91
|
-
return authored
|
|
92
|
+
return authored
|
|
93
|
+
? authoredMarkup(authored, live, sourceFile)
|
|
94
|
+
: liveMarkupWithoutPreviewMarks(live);
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
async function readSavedMarkup(
|
|
@@ -356,21 +359,17 @@ export function useClipboard({
|
|
|
356
359
|
|
|
357
360
|
const targetPath = activeCompPath || "index.html";
|
|
358
361
|
try {
|
|
359
|
-
const originalContent = await readFileContent(pid, targetPath);
|
|
360
|
-
let patchedContent: string;
|
|
361
362
|
let pastedIds: string[] = [];
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
const paste = (originalContent: string) => {
|
|
364
|
+
if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
|
|
364
365
|
const { currentTime, elements } = usePlayerStore.getState();
|
|
365
366
|
const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
|
|
367
|
+
pastedIds = pasted.ids;
|
|
366
368
|
// A clip pasted past the current composition end would exist in the
|
|
367
369
|
// file but never appear on the timeline or in playback/export (the
|
|
368
370
|
// root's data-duration is what actually bounds the render).
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
} else {
|
|
372
|
-
patchedContent = pasteElementHtml(originalContent, payload);
|
|
373
|
-
}
|
|
371
|
+
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
372
|
+
};
|
|
374
373
|
|
|
375
374
|
const label =
|
|
376
375
|
payload.kind === "timeline-clip"
|
|
@@ -380,8 +379,8 @@ export function useClipboard({
|
|
|
380
379
|
await saveProjectFilesWithHistory({
|
|
381
380
|
projectId: pid,
|
|
382
381
|
label,
|
|
383
|
-
files: { [targetPath]:
|
|
384
|
-
readFile:
|
|
382
|
+
files: { [targetPath]: paste },
|
|
383
|
+
readFile: (path) => readFileContent(pid, path),
|
|
385
384
|
writeFile: writeProjectFile,
|
|
386
385
|
recordEdit,
|
|
387
386
|
});
|
|
@@ -422,17 +421,19 @@ export function useClipboard({
|
|
|
422
421
|
|
|
423
422
|
try {
|
|
424
423
|
const clips = await readClips(targets);
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
424
|
+
let ids: string[] = [];
|
|
425
|
+
const duplicate = (originalContent: string) => {
|
|
426
|
+
const liveElements = usePlayerStore.getState().elements;
|
|
427
|
+
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
|
|
428
|
+
ids = pasted.ids;
|
|
429
|
+
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
430
|
+
};
|
|
430
431
|
|
|
431
432
|
await saveProjectFilesWithHistory({
|
|
432
433
|
projectId: pid,
|
|
433
434
|
label: clipLabel("Duplicate", clips.length),
|
|
434
|
-
files: { [targetPath]:
|
|
435
|
-
readFile:
|
|
435
|
+
files: { [targetPath]: duplicate },
|
|
436
|
+
readFile: (path) => readFileContent(pid, path),
|
|
436
437
|
writeFile: writeProjectFile,
|
|
437
438
|
recordEdit,
|
|
438
439
|
});
|
|
@@ -1252,8 +1252,15 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
1252
1252
|
|
|
1253
1253
|
const secondCommit = rendered.hook.handleDomStyleCommit("color", "green");
|
|
1254
1254
|
await flushAsyncWork();
|
|
1255
|
-
|
|
1255
|
+
// The newer commit waits for the older one in the file's queue.
|
|
1256
|
+
expect(patchCount).toBe(1);
|
|
1256
1257
|
|
|
1258
|
+
firstPatch.reject(new Error("server rejected blue"));
|
|
1259
|
+
await firstCommit;
|
|
1260
|
+
expect(element.style.getPropertyValue("color")).toBe("green");
|
|
1261
|
+
|
|
1262
|
+
await flushAsyncWork();
|
|
1263
|
+
expect(patchCount).toBe(2);
|
|
1257
1264
|
secondPatch.resolve(
|
|
1258
1265
|
jsonResponse({
|
|
1259
1266
|
ok: true,
|
|
@@ -1263,10 +1270,6 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
1263
1270
|
}),
|
|
1264
1271
|
);
|
|
1265
1272
|
await secondCommit;
|
|
1266
|
-
expect(element.style.getPropertyValue("color")).toBe("green");
|
|
1267
|
-
|
|
1268
|
-
firstPatch.reject(new Error("server rejected blue"));
|
|
1269
|
-
await firstCommit;
|
|
1270
1273
|
|
|
1271
1274
|
expect(element.style.getPropertyValue("color")).toBe("green");
|
|
1272
1275
|
} finally {
|
|
@@ -1698,15 +1701,16 @@ describe("useDomEditCommits attribute persist handling", () => {
|
|
|
1698
1701
|
try {
|
|
1699
1702
|
// Older commit's persist stays pending (captures previousValue=null); the
|
|
1700
1703
|
// newer commit captures previousValue="first-value" (the older commit's
|
|
1701
|
-
// optimistic apply) and
|
|
1704
|
+
// optimistic apply) and is still queued when the older one rejects. Without the
|
|
1702
1705
|
// per-key version guard, the stale rejection would revert to the older
|
|
1703
1706
|
// commit's own previousValue (null) and stomp the newer commit's value.
|
|
1704
1707
|
const firstCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
|
|
1705
1708
|
await act(async () => {
|
|
1706
|
-
|
|
1709
|
+
const secondCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
|
|
1710
|
+
first.reject(new Error("stale request failed"));
|
|
1711
|
+
await firstCommit;
|
|
1712
|
+
await secondCommit;
|
|
1707
1713
|
});
|
|
1708
|
-
first.reject(new Error("stale request failed"));
|
|
1709
|
-
await firstCommit;
|
|
1710
1714
|
|
|
1711
1715
|
expect(element.getAttribute("muted")).toBe("second-value");
|
|
1712
1716
|
} finally {
|
|
@@ -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,
|