@hyperframes/studio 0.8.86 → 0.8.88
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-w3Bbv0kd.js +473 -0
- package/dist/assets/{index-Cz3E_ZSj.js → index-B6PGJTzp.js} +1 -1
- package/dist/assets/{index-d5DKK15Q.js → index-DE6cZYR5.js} +199 -199
- package/dist/assets/{index-8Fgryj7q.js → index-DldknZPp.js} +1 -1
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
- package/dist/chunk-KU4F7QZ3.js.map +1 -0
- package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
- package/dist/chunk-PQFEGZWM.js.map +1 -0
- package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
- package/dist/index.d.ts +206 -160
- package/dist/index.html +2 -2
- package/dist/index.js +2121 -1702
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
- package/src/components/TimelineToolPicker.tsx +90 -0
- package/src/components/TimelineToolbar.tsx +2 -27
- package/src/components/editor/DomEditOverlay.tsx +1 -2
- package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
- package/src/components/editor/LayersPanel.test.ts +14 -0
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.tsx +2 -2
- package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
- package/src/components/editor/domEditOverlayGestures.ts +0 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
- package/src/components/editor/domEditing.test.ts +29 -0
- package/src/components/editor/domEditingDom.ts +1 -0
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/marqueeCommit.test.tsx +84 -0
- package/src/components/editor/marqueeCommit.ts +13 -1
- package/src/components/editor/useColorGradingController.test.ts +24 -0
- package/src/components/editor/useColorGradingController.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
- package/src/components/nle/useCompositionStack.test.tsx +53 -0
- package/src/components/nle/useCompositionStack.ts +4 -8
- package/src/hooks/appHotkeysDispatch.ts +8 -0
- package/src/hooks/domStyleCommit.ts +129 -0
- package/src/hooks/gsapEditOutcome.ts +2 -2
- package/src/hooks/gsapElementMatch.ts +38 -0
- package/src/hooks/gsapResizeIntercept.ts +10 -31
- package/src/hooks/gsapResizePreflight.ts +56 -0
- package/src/hooks/gsapRuntimeBridge.ts +66 -26
- package/src/hooks/keyframeCacheAstLoad.ts +4 -1
- package/src/hooks/useAppHotkeys.test.ts +58 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
- package/src/hooks/useDomEditCommits.ts +17 -182
- package/src/hooks/useDomEditPersist.ts +225 -0
- package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
- package/src/hooks/useDomEditPreviewSync.ts +2 -4
- package/src/hooks/useDomEditSession.test.tsx +51 -1
- package/src/hooks/useDomEditSession.ts +11 -2
- package/src/hooks/useDomEditTextCommits.ts +15 -99
- package/src/hooks/useDomStyleCommit.test.tsx +155 -0
- package/src/hooks/useDomStyleCommit.ts +74 -0
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
- package/src/hooks/useGsapAwareEditing.ts +1 -1
- package/src/hooks/useGsapTweenCache.ts +2 -48
- package/src/index.ts +10 -1
- package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
- package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
- package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
- package/src/player/components/TimelineGroupHeader.tsx +21 -10
- package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
- package/src/player/components/TimelineGroupRow.test.tsx +24 -33
- package/src/player/components/TimelineGroupRow.tsx +4 -1
- package/src/player/components/TimelineLanes.tsx +5 -5
- package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
- package/src/player/components/TimelineTrackHeader.tsx +6 -3
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineKeyframeData.ts +14 -0
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelineProviderState.tsx +10 -14
- package/src/player/components/useTimelineRangeSelection.ts +13 -12
- package/src/player/hooks/useShadowPreviewReload.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
- package/src/player/hooks/useTimelinePlayer.ts +29 -7
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/player/sceneSwap.test.ts +95 -0
- package/src/player/sceneSwap.ts +53 -0
- package/src/player/store/playerStore.ts +16 -0
- package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- package/dist/chunk-7PLTC6SV.js.map +0 -1
- /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
3
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
4
|
+
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
+
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
6
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
7
|
+
import {
|
|
8
|
+
DomEditPersistUnsafeValueError,
|
|
9
|
+
DomEditPersistUnresolvableError,
|
|
10
|
+
warnDomEditPersistNoOp,
|
|
11
|
+
} from "./domEditPersistFailure";
|
|
12
|
+
import {
|
|
13
|
+
formatUnsafeFieldList,
|
|
14
|
+
postPatchElement,
|
|
15
|
+
writePreparedContent,
|
|
16
|
+
} from "./useDomEditCommitsHelpers";
|
|
17
|
+
import type { CutoverResult } from "../utils/sdkCutover";
|
|
18
|
+
import { reseekPreviewRuntime } from "./timelineTrackVisibility";
|
|
19
|
+
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
20
|
+
import { readProjectFileContent } from "../utils/studioFileHistory";
|
|
21
|
+
|
|
22
|
+
export interface RecordEditInput {
|
|
23
|
+
label: string;
|
|
24
|
+
coalesceKey?: string;
|
|
25
|
+
coalesceMs?: number;
|
|
26
|
+
files: Record<string, { before: string; after: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseDomEditPersistParams {
|
|
30
|
+
activeCompPath: string | null;
|
|
31
|
+
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
32
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
33
|
+
queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
|
|
34
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
35
|
+
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
|
36
|
+
projectIdRef: React.MutableRefObject<string | null>;
|
|
37
|
+
reloadPreview: () => void;
|
|
38
|
+
forceReloadSdkSession?: () => void;
|
|
39
|
+
onTrySdkPersist?: (
|
|
40
|
+
selection: DomEditSelection,
|
|
41
|
+
operations: PatchOperation[],
|
|
42
|
+
originalContent: string,
|
|
43
|
+
targetPath: string,
|
|
44
|
+
options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
|
|
45
|
+
) => Promise<CutoverResult>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Studio's one DOM-edit writer: patches the source file, records history, then reloads or reseeks the preview. */
|
|
49
|
+
export function useDomEditPersist({
|
|
50
|
+
activeCompPath,
|
|
51
|
+
previewIframeRef,
|
|
52
|
+
showToast,
|
|
53
|
+
queueDomEditSave,
|
|
54
|
+
writeProjectFile,
|
|
55
|
+
editHistory,
|
|
56
|
+
projectIdRef,
|
|
57
|
+
reloadPreview,
|
|
58
|
+
forceReloadSdkSession,
|
|
59
|
+
onTrySdkPersist,
|
|
60
|
+
}: UseDomEditPersistParams): PersistDomEditOperations {
|
|
61
|
+
const reportedUnresolvableRef = useRef(new Set<string>());
|
|
62
|
+
|
|
63
|
+
// fallow-ignore-next-line complexity
|
|
64
|
+
const performPersistDomEditOperations = useCallback(
|
|
65
|
+
// fallow-ignore-next-line complexity
|
|
66
|
+
async (
|
|
67
|
+
selection: DomEditSelection,
|
|
68
|
+
operations: PatchOperation[],
|
|
69
|
+
options: Parameters<PersistDomEditOperations>[2],
|
|
70
|
+
expectedProjectId: string,
|
|
71
|
+
) => {
|
|
72
|
+
if (projectIdRef.current !== expectedProjectId) {
|
|
73
|
+
throw new Error("Active project changed before the edit could be saved");
|
|
74
|
+
}
|
|
75
|
+
const pid = expectedProjectId;
|
|
76
|
+
if (options?.shouldSave && !options.shouldSave()) return;
|
|
77
|
+
|
|
78
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
79
|
+
const completePersistence = <T>(result: T, changed: boolean): T => {
|
|
80
|
+
if (options?.skipRefresh && changed) reseekPreviewRuntime(previewIframeRef.current);
|
|
81
|
+
return result;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const readTarget = async (): Promise<string | null> => {
|
|
85
|
+
const content = await readProjectFileContent(pid, targetPath);
|
|
86
|
+
if (projectIdRef.current !== expectedProjectId) {
|
|
87
|
+
throw new Error("Active project changed before the edit could be saved");
|
|
88
|
+
}
|
|
89
|
+
return options?.shouldSave && !options.shouldSave() ? null : content;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Validate layout values BEFORE any persist path runs. The SDK cutover
|
|
93
|
+
// path (onTrySdkPersist) returns early on success, so leaving this check
|
|
94
|
+
// after it let invalid numeric values bypass the guard whenever the
|
|
95
|
+
// cutover flag was on.
|
|
96
|
+
const patchTarget = buildDomEditPatchTarget(selection);
|
|
97
|
+
const patchBody = { target: patchTarget, operations };
|
|
98
|
+
const unsafeFields = findUnsafeDomPatchValues(patchBody);
|
|
99
|
+
if (unsafeFields.length > 0) {
|
|
100
|
+
const fields = formatUnsafeFieldList(unsafeFields);
|
|
101
|
+
showToast("Couldn't save edit because it contains invalid layout values", "error");
|
|
102
|
+
throw new DomEditPersistUnsafeValueError(`DOM patch contains unsafe values: ${fields}`, {
|
|
103
|
+
alreadyToasted: true,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Skip the SDK path when prepareContent is set (e.g. @font-face injection
|
|
108
|
+
// for a custom font): sdkCutoverPersist serializes only the patched DOM
|
|
109
|
+
// and would drop the injected content. Let the server path run prepareContent.
|
|
110
|
+
// The SDK joins the file queue itself and re-reads there; this read is only its fallback.
|
|
111
|
+
if (onTrySdkPersist && !options?.prepareContent) {
|
|
112
|
+
const originalContent = await readTarget();
|
|
113
|
+
if (originalContent === null) return;
|
|
114
|
+
const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
|
|
115
|
+
label: options?.label,
|
|
116
|
+
coalesceKey: options?.coalesceKey,
|
|
117
|
+
skipRefresh: options?.skipRefresh,
|
|
118
|
+
});
|
|
119
|
+
if (cutover.status === "failed") throw cutover.error;
|
|
120
|
+
if (cutover.status === "committed") {
|
|
121
|
+
// SDK handled it — its in-memory doc is already current, so do NOT
|
|
122
|
+
// forceReload (that would echo-reload the session we just wrote).
|
|
123
|
+
return completePersistence(
|
|
124
|
+
{ sourceFile: targetPath, version: cutover.version, changed: true },
|
|
125
|
+
true,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const history = {
|
|
131
|
+
label: options?.label ?? "Edit layer",
|
|
132
|
+
coalesceKey: options?.coalesceKey,
|
|
133
|
+
coalesceMs: options?.coalesceMs,
|
|
134
|
+
};
|
|
135
|
+
const prepare = options?.prepareContent;
|
|
136
|
+
// Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
|
|
137
|
+
const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
|
|
138
|
+
const originalContent = await readTarget();
|
|
139
|
+
if (originalContent === null) return null;
|
|
140
|
+
const patchData = await postPatchElement(pid, targetPath, patchBody, showToast);
|
|
141
|
+
if (!patchData.changed) return { patchData, patchedContent: null, finalContent: null };
|
|
142
|
+
|
|
143
|
+
const patchedContent =
|
|
144
|
+
typeof patchData.content === "string" ? patchData.content : originalContent;
|
|
145
|
+
const finalContent = prepare
|
|
146
|
+
? await writePreparedContent(
|
|
147
|
+
targetPath,
|
|
148
|
+
patchedContent,
|
|
149
|
+
prepare,
|
|
150
|
+
writeProjectFile,
|
|
151
|
+
showToast,
|
|
152
|
+
)
|
|
153
|
+
: patchedContent;
|
|
154
|
+
|
|
155
|
+
await editHistory.recordEdit({
|
|
156
|
+
...history,
|
|
157
|
+
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
158
|
+
});
|
|
159
|
+
return { patchData, patchedContent, finalContent };
|
|
160
|
+
});
|
|
161
|
+
if (saved === null) return;
|
|
162
|
+
const { patchData, patchedContent, finalContent } = saved;
|
|
163
|
+
|
|
164
|
+
if (finalContent === null) {
|
|
165
|
+
if (patchData.matched === false) {
|
|
166
|
+
const targetKey = selection.selector ?? selection.id ?? "selection";
|
|
167
|
+
if (!reportedUnresolvableRef.current.has(targetKey)) {
|
|
168
|
+
reportedUnresolvableRef.current.add(targetKey);
|
|
169
|
+
trackStudioEvent("save_skipped_unresolvable", {
|
|
170
|
+
target_id: selection.id ?? undefined,
|
|
171
|
+
target_selector: selection.selector ?? undefined,
|
|
172
|
+
target_source_file: selection.sourceFile ?? undefined,
|
|
173
|
+
composition: activeCompPath ?? undefined,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
throw new DomEditPersistUnresolvableError(targetPath);
|
|
177
|
+
}
|
|
178
|
+
warnDomEditPersistNoOp(selection, operations);
|
|
179
|
+
return completePersistence(
|
|
180
|
+
typeof patchData.path === "string" && typeof patchData.version === "string"
|
|
181
|
+
? { sourceFile: patchData.path, version: patchData.version, changed: false }
|
|
182
|
+
: undefined,
|
|
183
|
+
false,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
forceReloadSdkSession?.();
|
|
187
|
+
|
|
188
|
+
if (!options?.skipRefresh) {
|
|
189
|
+
reloadPreview();
|
|
190
|
+
}
|
|
191
|
+
return completePersistence(
|
|
192
|
+
finalContent === patchedContent &&
|
|
193
|
+
typeof patchData.path === "string" &&
|
|
194
|
+
typeof patchData.version === "string"
|
|
195
|
+
? { sourceFile: patchData.path, version: patchData.version, changed: true }
|
|
196
|
+
: undefined,
|
|
197
|
+
true,
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
[
|
|
201
|
+
activeCompPath,
|
|
202
|
+
editHistory,
|
|
203
|
+
writeProjectFile,
|
|
204
|
+
projectIdRef,
|
|
205
|
+
reloadPreview,
|
|
206
|
+
showToast,
|
|
207
|
+
forceReloadSdkSession,
|
|
208
|
+
onTrySdkPersist,
|
|
209
|
+
previewIframeRef,
|
|
210
|
+
],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const persistDomEditOperations: PersistDomEditOperations = useCallback(
|
|
214
|
+
(selection, operations, options) => {
|
|
215
|
+
const expectedProjectId = projectIdRef.current;
|
|
216
|
+
if (!expectedProjectId) return Promise.reject(new Error("No active project"));
|
|
217
|
+
return queueDomEditSave(() =>
|
|
218
|
+
performPersistDomEditOperations(selection, operations, options, expectedProjectId),
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
[performPersistDomEditOperations, projectIdRef, queueDomEditSave],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return persistDomEditOperations;
|
|
225
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { createElement, act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
|
|
7
|
+
import { sceneSwapFor } from "../player/sceneSwap";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
describe("useDomEditPreviewSync", () => {
|
|
12
|
+
it("re-syncs the preview when edited scenes are swapped in, and not when the swap is refused", async () => {
|
|
13
|
+
const iframe = document.createElement("iframe");
|
|
14
|
+
let refuse = false;
|
|
15
|
+
Object.defineProperty(iframe, "contentWindow", {
|
|
16
|
+
value: {
|
|
17
|
+
__hfSwapScenes: async () => {
|
|
18
|
+
if (refuse) throw new Error("the film changed outside its scenes");
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(""));
|
|
23
|
+
const applyManualEdits = vi.fn(async () => {});
|
|
24
|
+
const refreshVersion = vi.fn();
|
|
25
|
+
function Harness() {
|
|
26
|
+
useDomEditPreviewSync({
|
|
27
|
+
previewIframe: iframe,
|
|
28
|
+
activeCompPath: null,
|
|
29
|
+
captionEditMode: false,
|
|
30
|
+
domEditSelectionRef: { current: null },
|
|
31
|
+
domEditGroupSelectionsRef: { current: [] },
|
|
32
|
+
domEditSelection: null,
|
|
33
|
+
refreshDomEditGroupSelectionsFromPreview: async () => {},
|
|
34
|
+
applyDomSelection: () => {},
|
|
35
|
+
buildDomSelectionFromTarget: async () => null,
|
|
36
|
+
refreshPreviewDocumentVersion: refreshVersion,
|
|
37
|
+
syncPreviewHotkeys: () => {},
|
|
38
|
+
applyStudioManualEditsToPreviewRef: { current: applyManualEdits },
|
|
39
|
+
});
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const root = createRoot(document.createElement("div"));
|
|
43
|
+
act(() => root.render(createElement(Harness)));
|
|
44
|
+
applyManualEdits.mockClear();
|
|
45
|
+
refreshVersion.mockClear();
|
|
46
|
+
|
|
47
|
+
refuse = true;
|
|
48
|
+
await act(() => sceneSwapFor(iframe)!("/preview", () => true).catch(() => {}));
|
|
49
|
+
expect(refreshVersion).not.toHaveBeenCalled();
|
|
50
|
+
|
|
51
|
+
refuse = false;
|
|
52
|
+
await act(() => sceneSwapFor(iframe)!("/preview", () => true));
|
|
53
|
+
expect(applyManualEdits).toHaveBeenCalledWith(iframe);
|
|
54
|
+
expect(refreshVersion).toHaveBeenCalledTimes(1);
|
|
55
|
+
act(() => root.unmount());
|
|
56
|
+
fetchSpy.mockRestore();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -9,6 +9,7 @@ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"
|
|
|
9
9
|
import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
|
|
10
10
|
import type { PatchTarget } from "../utils/sourcePatcher";
|
|
11
11
|
import { logSelect } from "../utils/selectDebug";
|
|
12
|
+
import { onPreviewContentReplaced } from "../player/sceneSwap";
|
|
12
13
|
|
|
13
14
|
interface UseDomEditPreviewSyncParams {
|
|
14
15
|
previewIframe: HTMLIFrameElement | null;
|
|
@@ -113,10 +114,7 @@ export function useDomEditPreviewSync({
|
|
|
113
114
|
refreshPreviewDocumentVersion();
|
|
114
115
|
};
|
|
115
116
|
|
|
116
|
-
previewIframe
|
|
117
|
-
return () => {
|
|
118
|
-
previewIframe.removeEventListener("load", handleLoad);
|
|
119
|
-
};
|
|
117
|
+
return onPreviewContentReplaced(previewIframe, handleLoad);
|
|
120
118
|
}, [
|
|
121
119
|
activeCompPath,
|
|
122
120
|
applyDomSelection,
|
|
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import { shouldUseSdkCutover } from "../utils/sdkCutover";
|
|
6
6
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
7
7
|
import type { Composition } from "@hyperframes/sdk";
|
|
8
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
8
9
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
9
10
|
import type { TimelineElement } from "../player";
|
|
10
11
|
import type { UseDomEditSessionParams } from "./useDomEditSession";
|
|
@@ -62,6 +63,8 @@ const domEditSelectionRef: { current: DomEditSelection | null } = { current: nul
|
|
|
62
63
|
const domEditGroupSelectionsRef: { current: DomEditSelection[] } = { current: [] };
|
|
63
64
|
const groupSelectionSpy = vi.fn();
|
|
64
65
|
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
|
|
66
|
+
const neverParsed = new Promise<{ animations: GsapAnimation[] }>(() => undefined);
|
|
67
|
+
const parsedFile = { current: neverParsed };
|
|
65
68
|
|
|
66
69
|
function createSessionParams(
|
|
67
70
|
overrides: Partial<UseDomEditSessionParams> = {},
|
|
@@ -166,7 +169,12 @@ vi.mock("./useAskAgentModal", () => ({
|
|
|
166
169
|
vi.mock("./useStudioSelectionPublisher", () => ({
|
|
167
170
|
useStudioSelectionPublisher: () => {},
|
|
168
171
|
}));
|
|
169
|
-
vi.mock("./
|
|
172
|
+
vi.mock("./keyframeCacheAstLoad", async (importOriginal) => ({
|
|
173
|
+
...(await importOriginal<typeof import("./keyframeCacheAstLoad")>()),
|
|
174
|
+
fetchParsedAnimations: () => parsedFile.current,
|
|
175
|
+
}));
|
|
176
|
+
vi.mock("./useGsapTweenCache", async (importOriginal) => ({
|
|
177
|
+
...(await importOriginal<typeof import("./useGsapTweenCache")>()),
|
|
170
178
|
useGsapCacheVersion: () => ({ version: 0, bump: vi.fn() }),
|
|
171
179
|
}));
|
|
172
180
|
vi.mock("./useGsapScriptCommits", () => ({
|
|
@@ -413,6 +421,48 @@ describe("bulk segment ease commits", () => {
|
|
|
413
421
|
});
|
|
414
422
|
});
|
|
415
423
|
|
|
424
|
+
describe("the session hands out selections narrowed to what the GSAP commit accepts", () => {
|
|
425
|
+
it("closes the move of a helper-loop element once its preflight lands", async () => {
|
|
426
|
+
const { useDomEditSession } = await import("./useDomEditSession");
|
|
427
|
+
domEditSelectionRef.current = {
|
|
428
|
+
id: "hero",
|
|
429
|
+
selector: "#hero",
|
|
430
|
+
element: document.createElement("div"),
|
|
431
|
+
sourceFile: "index.html",
|
|
432
|
+
capabilities: {
|
|
433
|
+
canApplyManualOffset: true,
|
|
434
|
+
canApplyManualSize: true,
|
|
435
|
+
canApplyManualRotation: true,
|
|
436
|
+
},
|
|
437
|
+
} as unknown as DomEditSelection;
|
|
438
|
+
const loop = { targetSelector: "#hero", properties: { x: 100 }, provenance: { kind: "loop" } };
|
|
439
|
+
parsedFile.current = Promise.resolve({ animations: [loop as unknown as GsapAnimation] });
|
|
440
|
+
const seen: { selection?: DomEditSelection | null } = {};
|
|
441
|
+
function Probe() {
|
|
442
|
+
seen.selection = useDomEditSession(createSessionParams()).domEditSelection;
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
const root = createRoot(document.createElement("div"));
|
|
446
|
+
try {
|
|
447
|
+
act(() => root.render(<Probe />));
|
|
448
|
+
expect(seen.selection?.capabilities.canApplyManualOffset).toBe(false);
|
|
449
|
+
await act(async () => {
|
|
450
|
+
for (let tick = 0; tick < 10; tick++) await Promise.resolve();
|
|
451
|
+
});
|
|
452
|
+
expect(seen.selection?.capabilities).toMatchObject({
|
|
453
|
+
canApplyManualOffset: false,
|
|
454
|
+
canApplyManualSize: true,
|
|
455
|
+
canApplyManualRotation: true,
|
|
456
|
+
reasonIfDisabled: expect.stringContaining("helper or loop"),
|
|
457
|
+
});
|
|
458
|
+
} finally {
|
|
459
|
+
domEditSelectionRef.current = null;
|
|
460
|
+
parsedFile.current = neverParsed;
|
|
461
|
+
act(() => root.unmount());
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
416
466
|
// ── Grouping refuses audio ───────────────────────────────────────────────────
|
|
417
467
|
//
|
|
418
468
|
// A layout group is a positioned wrapper: it takes the members' bounding box,
|
|
@@ -19,6 +19,7 @@ import { useDomEditWiring } from "./useDomEditWiring";
|
|
|
19
19
|
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
20
20
|
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
|
21
21
|
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
|
|
22
|
+
import { useCommitPreflightCapabilities } from "./useCommitPreflightCapabilities";
|
|
22
23
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
23
24
|
import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
|
|
24
25
|
import type { RecordEditInput } from "./domEditDeleteMembers";
|
|
@@ -494,9 +495,17 @@ export function useDomEditSession({
|
|
|
494
495
|
});
|
|
495
496
|
const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } =
|
|
496
497
|
useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef });
|
|
498
|
+
const committable = useCommitPreflightCapabilities({
|
|
499
|
+
projectId,
|
|
500
|
+
enabled: gsapCommitMutation !== null,
|
|
501
|
+
selection: domEditSelection,
|
|
502
|
+
groupSelections: domEditGroupSelections,
|
|
503
|
+
previewIframeRef,
|
|
504
|
+
version: gsapCacheVersion,
|
|
505
|
+
});
|
|
497
506
|
return {
|
|
498
|
-
domEditSelection,
|
|
499
|
-
domEditGroupSelections,
|
|
507
|
+
domEditSelection: committable.selection,
|
|
508
|
+
domEditGroupSelections: committable.groupSelections,
|
|
500
509
|
domEditHoverSelection,
|
|
501
510
|
activeGroupElement,
|
|
502
511
|
agentModalOpen,
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
isImageBackgroundValue,
|
|
5
|
-
isManualGeometryStyleProperty,
|
|
6
|
-
normalizeDomEditStyleValue,
|
|
7
|
-
} from "../utils/studioHelpers";
|
|
2
|
+
import { normalizeDomEditStyleValue } from "../utils/studioHelpers";
|
|
8
3
|
import {
|
|
9
4
|
injectPreviewGoogleFont,
|
|
10
5
|
injectPreviewImportedFont,
|
|
@@ -12,7 +7,6 @@ import {
|
|
|
12
7
|
} from "../utils/studioFontHelpers";
|
|
13
8
|
import {
|
|
14
9
|
buildDomEditRichTextPatchOperation,
|
|
15
|
-
buildDomEditStylePatchOperation,
|
|
16
10
|
findElementForSelection,
|
|
17
11
|
getDomEditTargetKey,
|
|
18
12
|
isTextEditableSelection,
|
|
@@ -32,6 +26,7 @@ import {
|
|
|
32
26
|
runReportedDomEditCommit,
|
|
33
27
|
type DomEditCommitOutcome,
|
|
34
28
|
} from "./domEditCommitRunner";
|
|
29
|
+
import { commitDomStyles } from "./domStyleCommit";
|
|
35
30
|
import { useDomEditAttributeCommits } from "./useDomEditAttributeCommits";
|
|
36
31
|
import type { InlineTextEditCommit } from "./useInlineTextEdit";
|
|
37
32
|
|
|
@@ -70,24 +65,6 @@ function ownsCurrentPreviewElement(
|
|
|
70
65
|
return element === selection.element && element.ownerDocument === document;
|
|
71
66
|
}
|
|
72
67
|
|
|
73
|
-
function buildDomStyleCommitOperations(
|
|
74
|
-
property: string,
|
|
75
|
-
value: string,
|
|
76
|
-
isImageBackgroundCommit: boolean,
|
|
77
|
-
): PatchOperation[] {
|
|
78
|
-
const operations: PatchOperation[] = [
|
|
79
|
-
buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
|
|
80
|
-
];
|
|
81
|
-
if (isImageBackgroundCommit) {
|
|
82
|
-
operations.push(
|
|
83
|
-
buildDomEditStylePatchOperation("background-position", "center"),
|
|
84
|
-
buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
|
|
85
|
-
buildDomEditStylePatchOperation("background-size", "contain"),
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
return operations;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
68
|
async function resyncDomTextSelectionFromPreview(
|
|
92
69
|
doc: Document | null | undefined,
|
|
93
70
|
selection: DomEditSelection,
|
|
@@ -138,81 +115,20 @@ export function useDomEditTextCommits({
|
|
|
138
115
|
});
|
|
139
116
|
|
|
140
117
|
const handleDomStyleCommitForSelection = useCallback(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const styleCommitKey = `${getDomEditTargetKey(selection)}:${property}`;
|
|
152
|
-
const isLatestStyleCommit = bumpDomEditCommitMapVersion(
|
|
153
|
-
domStyleCommitVersionRef.current,
|
|
154
|
-
styleCommitKey,
|
|
155
|
-
);
|
|
156
|
-
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
157
|
-
const iframe = previewIframeRef.current;
|
|
158
|
-
const doc = iframe?.contentDocument;
|
|
159
|
-
const normalizedValue = normalizeDomEditStyleValue(property, value);
|
|
160
|
-
const isImageBackgroundCommit =
|
|
161
|
-
property === "background-image" && isImageBackgroundValue(value);
|
|
162
|
-
let editedElement: HTMLElement | null = null;
|
|
163
|
-
let previousInlineValue: string | null = null;
|
|
164
|
-
const operations = buildDomStyleCommitOperations(property, value, isImageBackgroundCommit);
|
|
165
|
-
// Inline-style commits never full-reload the preview (that blanks the iframe
|
|
166
|
-
// until it re-renders): the live element was already mutated optimistically in
|
|
167
|
-
// apply(). z-index is no exception — setting `element.style.zIndex` restacks the
|
|
168
|
-
// element in-browser immediately, so a reload would only cost a black blink.
|
|
169
|
-
const skipRefresh = true;
|
|
170
|
-
|
|
171
|
-
return runReportedDomEditCommit({
|
|
172
|
-
capture: () => {
|
|
173
|
-
if (!doc) return;
|
|
174
|
-
const el = findElementForSelection(doc, selection, activeCompPath);
|
|
175
|
-
if (!el) return;
|
|
176
|
-
editedElement = el;
|
|
177
|
-
previousInlineValue = el.style.getPropertyValue(property);
|
|
178
|
-
},
|
|
179
|
-
apply: () => {
|
|
180
|
-
if (!editedElement) return;
|
|
181
|
-
editedElement.style.setProperty(property, normalizedValue);
|
|
182
|
-
if (property === "font-family" && doc) {
|
|
183
|
-
injectPreviewGoogleFont(doc, value);
|
|
184
|
-
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
185
|
-
}
|
|
186
|
-
if (isImageBackgroundCommit) {
|
|
187
|
-
editedElement.style.setProperty("background-position", "center");
|
|
188
|
-
editedElement.style.setProperty("background-repeat", "no-repeat");
|
|
189
|
-
editedElement.style.setProperty("background-size", "contain");
|
|
190
|
-
}
|
|
191
|
-
},
|
|
192
|
-
persist: () =>
|
|
193
|
-
persistDomEditOperations(selection, operations, {
|
|
194
|
-
label: "Edit layer style",
|
|
195
|
-
skipRefresh,
|
|
196
|
-
prepareContent: importedFont
|
|
197
|
-
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
198
|
-
: undefined,
|
|
199
|
-
}),
|
|
200
|
-
shouldRevert: () => isLatestStyleCommit(),
|
|
201
|
-
revert: () => {
|
|
202
|
-
if (!editedElement || previousInlineValue === null) return;
|
|
203
|
-
// ponytail: background-image side-effect styles are not reverted here.
|
|
204
|
-
if (previousInlineValue === "") {
|
|
205
|
-
editedElement.style.removeProperty(property);
|
|
206
|
-
} else {
|
|
207
|
-
editedElement.style.setProperty(property, previousInlineValue);
|
|
208
|
-
}
|
|
118
|
+
(selection: DomEditSelection, property: string, value: string): Promise<DomEditCommitOutcome> =>
|
|
119
|
+
commitDomStyles(
|
|
120
|
+
{
|
|
121
|
+
activeCompPath,
|
|
122
|
+
previewIframeRef,
|
|
123
|
+
persistDomEditOperations,
|
|
124
|
+
showToast,
|
|
125
|
+
versions: domStyleCommitVersionRef.current,
|
|
126
|
+
resolveImportedFontAsset,
|
|
127
|
+
resync: refreshDomEditSelectionFromPreview,
|
|
209
128
|
},
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
onFinally: isLatestStyleCommit.release,
|
|
214
|
-
});
|
|
215
|
-
},
|
|
129
|
+
selection,
|
|
130
|
+
{ [property]: value },
|
|
131
|
+
),
|
|
216
132
|
[
|
|
217
133
|
activeCompPath,
|
|
218
134
|
persistDomEditOperations,
|