@hyperframes/studio 0.8.116 → 0.8.118
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-mGYvPVpw.js → hyperframes-player-CnpIuNRl.js} +1 -1
- package/dist/assets/{index-DYNaXPVf.js → index-B4SbAADe.js} +196 -194
- package/dist/assets/{index-Dr3hNpvS.js → index-BZ23-h1_.js} +1 -1
- package/dist/assets/{index-BWHt0-ir.js → index-FeRU6G3G.js} +1 -1
- package/dist/index.d.ts +29 -19
- package/dist/index.html +1 -1
- package/dist/index.js +2366 -2305
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/StudioRightPanels.tsx +3 -6
- package/src/components/TimelineToolbar.test.tsx +14 -0
- package/src/components/TimelineToolbar.tsx +4 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +149 -25
- package/src/components/editor/PropertyPanel.tsx +1 -12
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +2 -3
- package/src/components/editor/PropertyPanelEmptyState.tsx +3 -5
- package/src/components/editor/PropertyPanelFlat.tsx +9 -22
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +7 -4
- package/src/components/editor/PropertyPanelFlatHeader.tsx +5 -3
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/manualOffsetDrag.ts +3 -3
- package/src/components/editor/plainTranslate.ts +5 -3
- package/src/components/editor/propertyPanelHelpers.ts +2 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
- package/src/components/editor/rotationDraft.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
- package/src/components/editor/usePanelHiddenToggle.ts +44 -0
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
- package/src/hooks/gsapScriptCommitTypes.ts +1 -1
- package/src/hooks/plainRotation.ts +5 -1
- package/src/hooks/timelineTrackVisibility.ts +7 -17
- package/src/hooks/useDomEditPersist.test.tsx +335 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
- package/src/hooks/useDomGeometryCommit.ts +6 -9
- package/src/hooks/useFreezeFrame.test.ts +39 -3
- package/src/hooks/useFreezeFrame.ts +4 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +25 -0
- package/src/hooks/useGsapAwareEditing.ts +12 -8
- package/src/hooks/useGsapAwareGroupMove.ts +2 -2
- package/src/hooks/useGsapScriptCommits.test.tsx +69 -1
- package/src/hooks/useGsapScriptCommits.ts +9 -7
- package/src/hooks/usePersistentEditHistory.test.ts +25 -2
- package/src/hooks/usePersistentEditHistory.ts +3 -9
- package/src/player/components/Timeline.test.ts +48 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +14 -22
- package/src/player/components/TimelineTrackHeader.tsx +4 -2
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +17 -31
- package/src/player/components/TimelineTrackPlainHeader.tsx +9 -32
- package/src/player/components/hiddenToggle.tsx +20 -0
- package/src/utils/gsapSoftReload.test.ts +51 -2
- package/src/utils/gsapSoftReload.ts +11 -21
- package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
- package/src/utils/softReloadTargets.ts +4 -3
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/timelineInspector.ts +10 -5
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
2
|
+
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
3
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
4
|
+
import { hiddenToggleVerb } from "../../player/components/hiddenToggle";
|
|
5
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
6
|
+
|
|
7
|
+
/** The panel header's hide toggle, or undefined when this selection has no writer for it. */
|
|
8
|
+
export function usePanelHiddenToggle({
|
|
9
|
+
element,
|
|
10
|
+
hidden,
|
|
11
|
+
selectedElementId,
|
|
12
|
+
onToggleElementHidden,
|
|
13
|
+
}: {
|
|
14
|
+
element: Pick<DomEditSelection, "id" | "tagName">;
|
|
15
|
+
hidden: boolean;
|
|
16
|
+
selectedElementId: string | null | undefined;
|
|
17
|
+
onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
|
|
18
|
+
}): (() => void) | undefined {
|
|
19
|
+
const { onSetAudioGroupAttributeQuiet } = useTimelineEditContextOptional();
|
|
20
|
+
const domEditActions = useDomEditActionsContextOptional();
|
|
21
|
+
// A bus has no timeline row, so it mutes through the group's own writer.
|
|
22
|
+
const audioGroupId = element.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ? element.id : null;
|
|
23
|
+
const write = audioGroupId
|
|
24
|
+
? onSetAudioGroupAttributeQuiet &&
|
|
25
|
+
(() =>
|
|
26
|
+
onSetAudioGroupAttributeQuiet(
|
|
27
|
+
audioGroupId,
|
|
28
|
+
"data-hidden",
|
|
29
|
+
hidden ? null : "",
|
|
30
|
+
`${hiddenToggleVerb(true, hidden)} element`,
|
|
31
|
+
))
|
|
32
|
+
: selectedElementId && onToggleElementHidden
|
|
33
|
+
? () => onToggleElementHidden(selectedElementId, !hidden)
|
|
34
|
+
: undefined;
|
|
35
|
+
// The label reads the selection snapshot, so re-read whatever is selected once the write lands.
|
|
36
|
+
return (
|
|
37
|
+
write &&
|
|
38
|
+
(() =>
|
|
39
|
+
void Promise.resolve(write()).then(() => {
|
|
40
|
+
const selection = domEditActions?.domEditSelectionRef.current;
|
|
41
|
+
if (selection) void domEditActions?.refreshDomEditSelectionFromPreview(selection);
|
|
42
|
+
}))
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -77,6 +77,7 @@ const SAVES_AN_EDIT = {
|
|
|
77
77
|
handleUnroll: true,
|
|
78
78
|
invalidateGsapCache: false,
|
|
79
79
|
previewIframeRef: false,
|
|
80
|
+
domEditSelectionRef: false,
|
|
80
81
|
commitMutation: true,
|
|
81
82
|
applyMarqueeSelection: false,
|
|
82
83
|
handleUpdateKeyframeEase: true,
|
|
@@ -329,6 +330,7 @@ export function DomEditProvider({
|
|
|
329
330
|
handleUnroll,
|
|
330
331
|
invalidateGsapCache,
|
|
331
332
|
previewIframeRef,
|
|
333
|
+
domEditSelectionRef,
|
|
332
334
|
commitMutation: stableCommitMutation,
|
|
333
335
|
applyMarqueeSelection,
|
|
334
336
|
handleUpdateKeyframeEase,
|
|
@@ -406,6 +408,7 @@ export function DomEditProvider({
|
|
|
406
408
|
handleUnroll,
|
|
407
409
|
invalidateGsapCache,
|
|
408
410
|
previewIframeRef,
|
|
411
|
+
domEditSelectionRef,
|
|
409
412
|
stableCommitMutation,
|
|
410
413
|
applyMarqueeSelection,
|
|
411
414
|
handleUpdateKeyframeEase,
|
|
@@ -449,12 +449,12 @@ export const ROTATION_CHANNELS: string[] = [
|
|
|
449
449
|
|
|
450
450
|
/** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
|
|
451
451
|
* transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
|
|
452
|
-
|
|
452
|
+
function gsapWritesPosition(el: Element): boolean {
|
|
453
453
|
return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS);
|
|
454
454
|
}
|
|
455
455
|
|
|
456
456
|
/** `gsapWritesPosition` for a rotate: everything else turns by its own CSS `rotate`. */
|
|
457
|
-
|
|
457
|
+
function gsapWritesRotation(el: Element): boolean {
|
|
458
458
|
return gsapRendersTransform(el) || gsapWritesChannels(el, ROTATION_CHANNELS);
|
|
459
459
|
}
|
|
460
460
|
|
|
@@ -474,10 +474,23 @@ const BOX_CHANNELS = [
|
|
|
474
474
|
];
|
|
475
475
|
|
|
476
476
|
/** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */
|
|
477
|
-
|
|
477
|
+
function gsapWritesBox(el: Element): boolean {
|
|
478
478
|
return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS);
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
export type EditGesture = "move" | "resize" | "rotate";
|
|
482
|
+
const GSAP_OWNS: Record<EditGesture, (el: Element) => boolean> = {
|
|
483
|
+
move: gsapWritesPosition,
|
|
484
|
+
resize: gsapWritesBox,
|
|
485
|
+
rotate: gsapWritesRotation,
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/** The one GSAP-or-CSS route decision. Make it when an edit starts and carry it: mid-gesture
|
|
489
|
+
* writes make GSAP render the element, so asking again at commit can flip the route. */
|
|
490
|
+
export function editsPlainCss(el: Element, gesture: EditGesture): boolean {
|
|
491
|
+
return !GSAP_OWNS[gesture](el);
|
|
492
|
+
}
|
|
493
|
+
|
|
481
494
|
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
|
|
482
495
|
export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
|
|
483
496
|
const win = el.ownerDocument.defaultView as {
|
|
@@ -44,7 +44,7 @@ export interface CommitMutationOptions {
|
|
|
44
44
|
* error handling — a failed write still throws.
|
|
45
45
|
*/
|
|
46
46
|
deferPreviewSync?: boolean;
|
|
47
|
-
/**
|
|
47
|
+
/** Carries a deferred write not yet on screen (no patch, or a missed one) to the final render. */
|
|
48
48
|
previewFallbackLatch?: { pending: boolean };
|
|
49
49
|
beforeReload?: () => void;
|
|
50
50
|
/**
|
|
@@ -27,7 +27,11 @@ export function savePlainRotation(
|
|
|
27
27
|
? buildClearRotationPatches(element)
|
|
28
28
|
: [];
|
|
29
29
|
if (patches.length) clearStudioRotation(element);
|
|
30
|
-
const drawn = applyCssRotation(
|
|
30
|
+
const drawn = applyCssRotation(
|
|
31
|
+
element,
|
|
32
|
+
next.angle,
|
|
33
|
+
patches.length ? undefined : (next.plain ?? undefined),
|
|
34
|
+
);
|
|
31
35
|
patches.push(...drawn);
|
|
32
36
|
const turn = drawn.at(-1)!;
|
|
33
37
|
return commitPositionPatchToHtml(selection, patches, {
|
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
trackDisplaySuffix,
|
|
9
9
|
} from "../player/components/timelineTrackDisplay";
|
|
10
10
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
11
|
-
import {
|
|
11
|
+
import { isAudioOnlyTrack } from "../utils/timelineInspector";
|
|
12
|
+
import { hiddenToggleVerb } from "../player/components/hiddenToggle";
|
|
12
13
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
13
14
|
import {
|
|
14
15
|
findTimelineElementInIframe,
|
|
@@ -206,14 +207,8 @@ export async function toggleTimelineTrackHidden({
|
|
|
206
207
|
displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
|
207
208
|
);
|
|
208
209
|
const trackElements = timelineElements.filter((element) => element.track === track);
|
|
209
|
-
const
|
|
210
|
-
const label = isAudioOnlyTrack
|
|
211
|
-
? hidden
|
|
212
|
-
? `Mute track${suffix}`
|
|
213
|
-
: `Unmute track${suffix}`
|
|
214
|
-
: hidden
|
|
215
|
-
? `Hide track${suffix}`
|
|
216
|
-
: `Show track${suffix}`;
|
|
210
|
+
const hiddenBefore = !hidden;
|
|
211
|
+
const label = `${hiddenToggleVerb(isAudioOnlyTrack(trackElements), hiddenBefore)} track${suffix}`;
|
|
217
212
|
return setElementsHidden({
|
|
218
213
|
projectId,
|
|
219
214
|
activeCompPath,
|
|
@@ -240,19 +235,14 @@ export async function toggleTimelineElementHidden({
|
|
|
240
235
|
}: ToggleTimelineElementHiddenInput): Promise<string[]> {
|
|
241
236
|
const keys = new Set(typeof elementKey === "string" ? [elementKey] : elementKey);
|
|
242
237
|
const elements = timelineElements.filter((item) => keys.has(item.key ?? item.id));
|
|
238
|
+
const hiddenBefore = !hidden;
|
|
239
|
+
const verb = hiddenToggleVerb(isAudioOnlyTrack(elements), hiddenBefore);
|
|
243
240
|
return setElementsHidden({
|
|
244
241
|
projectId,
|
|
245
242
|
activeCompPath,
|
|
246
243
|
elements,
|
|
247
244
|
hidden,
|
|
248
|
-
label:
|
|
249
|
-
elements.length > 1
|
|
250
|
-
? hidden
|
|
251
|
-
? `Hide ${elements.length} elements`
|
|
252
|
-
: `Show ${elements.length} elements`
|
|
253
|
-
: hidden
|
|
254
|
-
? "Hide element"
|
|
255
|
-
: "Show element",
|
|
245
|
+
label: elements.length > 1 ? `${verb} ${elements.length} elements` : `${verb} element`,
|
|
256
246
|
previewIframe,
|
|
257
247
|
writeProjectFile,
|
|
258
248
|
recordEdit,
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
6
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
7
|
+
import type { CutoverResult } from "../utils/sdkCutover";
|
|
8
|
+
import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
|
|
9
|
+
import {
|
|
10
|
+
DomEditPersistPreparedWriteError,
|
|
11
|
+
DomEditPersistUnresolvableError,
|
|
12
|
+
DomEditPersistUnsafeValueError,
|
|
13
|
+
} from "./domEditPersistFailure";
|
|
14
|
+
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
15
|
+
import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
|
|
16
|
+
import { useDomEditPersist, type UseDomEditPersistParams } from "./useDomEditPersist";
|
|
17
|
+
|
|
18
|
+
const trackStudioEvent = vi.fn();
|
|
19
|
+
const reseekPreviewRuntime = vi.fn();
|
|
20
|
+
vi.mock("../utils/studioTelemetry", () => ({
|
|
21
|
+
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("./timelineTrackVisibility", () => ({
|
|
24
|
+
reseekPreviewRuntime: (...args: unknown[]) => reseekPreviewRuntime(...args),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
28
|
+
|
|
29
|
+
const SOURCE = '<div data-hf-id="hf-card" style="color: red">Card</div>';
|
|
30
|
+
const PATCHED = '<div data-hf-id="hf-card" style="color: blue">Card</div>';
|
|
31
|
+
const OPERATIONS: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
|
|
32
|
+
const selection = {
|
|
33
|
+
hfId: "hf-card",
|
|
34
|
+
selector: '[data-hf-id="hf-card"]',
|
|
35
|
+
selectorIndex: 0,
|
|
36
|
+
sourceFile: "index.html",
|
|
37
|
+
} as DomEditSelection;
|
|
38
|
+
|
|
39
|
+
function stubServer(patchResponse: Record<string, unknown>) {
|
|
40
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
|
41
|
+
const url = requestUrl(input);
|
|
42
|
+
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: SOURCE });
|
|
43
|
+
if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
|
|
44
|
+
return jsonResponse(patchResponse);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
47
|
+
});
|
|
48
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
49
|
+
return fetchMock;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CHANGED = { changed: true, content: PATCHED, path: "index.html", version: "v2" };
|
|
53
|
+
const PREPARED = PATCHED.replace("Card", "Card!");
|
|
54
|
+
const sdkResult = (result: CutoverResult) => vi.fn(async (): Promise<CutoverResult> => result);
|
|
55
|
+
|
|
56
|
+
const patchPosts = (fetchMock: ReturnType<typeof stubServer>) =>
|
|
57
|
+
fetchMock.mock.calls.filter(([input]) => requestUrl(input).includes("/patch-element/"));
|
|
58
|
+
|
|
59
|
+
let unmount: (() => void) | null = null;
|
|
60
|
+
|
|
61
|
+
function renderPersist(over: Partial<UseDomEditPersistParams> = {}) {
|
|
62
|
+
const params: UseDomEditPersistParams = {
|
|
63
|
+
activeCompPath: "index.html",
|
|
64
|
+
previewIframeRef: { current: null },
|
|
65
|
+
showToast: vi.fn(),
|
|
66
|
+
queueDomEditSave: (save) => save(),
|
|
67
|
+
writeProjectFile: vi.fn(async () => {}),
|
|
68
|
+
editHistory: { recordEdit: vi.fn(async () => {}) },
|
|
69
|
+
projectIdRef: { current: "p1" },
|
|
70
|
+
reloadPreview: vi.fn(),
|
|
71
|
+
forceReloadSdkSession: vi.fn(),
|
|
72
|
+
...over,
|
|
73
|
+
};
|
|
74
|
+
const captured: { persist: PersistDomEditOperations | null } = { persist: null };
|
|
75
|
+
function Probe() {
|
|
76
|
+
captured.persist = useDomEditPersist(params);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const root = createRoot(document.createElement("div"));
|
|
80
|
+
act(() => root.render(createElement(Probe)));
|
|
81
|
+
unmount = () => act(() => root.unmount());
|
|
82
|
+
if (!captured.persist) throw new Error("hook did not initialize");
|
|
83
|
+
return { persist: captured.persist, params };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("useDomEditPersist", () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
trackStudioEvent.mockReset();
|
|
89
|
+
reseekPreviewRuntime.mockReset();
|
|
90
|
+
});
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
unmount?.();
|
|
93
|
+
unmount = null;
|
|
94
|
+
vi.unstubAllGlobals();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("patches the source, records the edit and reloads the preview", async () => {
|
|
98
|
+
const fetchMock = stubServer({
|
|
99
|
+
changed: true,
|
|
100
|
+
content: PATCHED,
|
|
101
|
+
path: "index.html",
|
|
102
|
+
version: "v2",
|
|
103
|
+
});
|
|
104
|
+
const { persist, params } = renderPersist();
|
|
105
|
+
|
|
106
|
+
const outcome = await persist(selection, OPERATIONS, { label: "Recolor" });
|
|
107
|
+
|
|
108
|
+
expect(outcome).toEqual({ sourceFile: "index.html", version: "v2", changed: true });
|
|
109
|
+
const [, init] = patchPosts(fetchMock)[0]!;
|
|
110
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
111
|
+
target: { hfId: "hf-card", selector: '[data-hf-id="hf-card"]', selectorIndex: 0 },
|
|
112
|
+
operations: OPERATIONS,
|
|
113
|
+
});
|
|
114
|
+
expect(params.editHistory.recordEdit).toHaveBeenCalledWith({
|
|
115
|
+
label: "Recolor",
|
|
116
|
+
coalesceKey: undefined,
|
|
117
|
+
coalesceMs: undefined,
|
|
118
|
+
files: { "index.html": { before: SOURCE, after: PATCHED } },
|
|
119
|
+
});
|
|
120
|
+
expect(params.forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
121
|
+
expect(params.reloadPreview).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(reseekPreviewRuntime).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("with skipRefresh reseeks the preview instead of reloading it", async () => {
|
|
126
|
+
stubServer({ changed: true, content: PATCHED, path: "index.html", version: "v2" });
|
|
127
|
+
const { persist, params } = renderPersist();
|
|
128
|
+
|
|
129
|
+
await persist(selection, OPERATIONS, { skipRefresh: true });
|
|
130
|
+
|
|
131
|
+
expect(params.reloadPreview).not.toHaveBeenCalled();
|
|
132
|
+
expect(reseekPreviewRuntime).toHaveBeenCalledTimes(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("an unchanged patch records nothing and reports changed: false", async () => {
|
|
136
|
+
stubServer({ changed: false, matched: true, path: "index.html", version: "v1" });
|
|
137
|
+
const { persist, params } = renderPersist();
|
|
138
|
+
|
|
139
|
+
const outcome = await persist(selection, OPERATIONS);
|
|
140
|
+
|
|
141
|
+
expect(outcome).toEqual({ sourceFile: "index.html", version: "v1", changed: false });
|
|
142
|
+
expect(params.editHistory.recordEdit).not.toHaveBeenCalled();
|
|
143
|
+
expect(params.reloadPreview).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("an unmatched target throws and is reported once per target", async () => {
|
|
147
|
+
stubServer({ changed: false, matched: false });
|
|
148
|
+
const { persist } = renderPersist();
|
|
149
|
+
|
|
150
|
+
await expect(persist(selection, OPERATIONS)).rejects.toBeInstanceOf(
|
|
151
|
+
DomEditPersistUnresolvableError,
|
|
152
|
+
);
|
|
153
|
+
await expect(persist(selection, OPERATIONS)).rejects.toBeInstanceOf(
|
|
154
|
+
DomEditPersistUnresolvableError,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
|
158
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
159
|
+
"save_skipped_unresolvable",
|
|
160
|
+
expect.objectContaining({ target_selector: '[data-hf-id="hf-card"]' }),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("refuses a non-finite layout value before any request, with a toast", async () => {
|
|
165
|
+
const fetchMock = stubServer({ changed: true });
|
|
166
|
+
const { persist, params } = renderPersist();
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
persist({ ...selection, selectorIndex: Number.NaN }, OPERATIONS),
|
|
170
|
+
).rejects.toBeInstanceOf(DomEditPersistUnsafeValueError);
|
|
171
|
+
|
|
172
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
173
|
+
expect(params.showToast).toHaveBeenCalledWith(
|
|
174
|
+
"Couldn't save edit because it contains invalid layout values",
|
|
175
|
+
"error",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("a committed SDK cutover skips the server patch and the reload", async () => {
|
|
180
|
+
const fetchMock = stubServer({ changed: true });
|
|
181
|
+
const onTrySdkPersist = vi.fn(
|
|
182
|
+
async (): Promise<CutoverResult> => ({
|
|
183
|
+
status: "committed",
|
|
184
|
+
version: "sdk-3",
|
|
185
|
+
before: SOURCE,
|
|
186
|
+
after: PATCHED,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
const { persist, params } = renderPersist({ onTrySdkPersist });
|
|
190
|
+
|
|
191
|
+
const outcome = await persist(selection, OPERATIONS, { label: "Recolor" });
|
|
192
|
+
|
|
193
|
+
expect(outcome).toEqual({ sourceFile: "index.html", version: "sdk-3", changed: true });
|
|
194
|
+
expect(onTrySdkPersist).toHaveBeenCalledWith(selection, OPERATIONS, SOURCE, "index.html", {
|
|
195
|
+
label: "Recolor",
|
|
196
|
+
coalesceKey: undefined,
|
|
197
|
+
skipRefresh: undefined,
|
|
198
|
+
});
|
|
199
|
+
expect(patchPosts(fetchMock)).toHaveLength(0);
|
|
200
|
+
expect(params.reloadPreview).not.toHaveBeenCalled();
|
|
201
|
+
expect(params.forceReloadSdkSession).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("records the edit once, and reloads only after the record has settled", async () => {
|
|
205
|
+
stubServer(CHANGED);
|
|
206
|
+
let finishRecord = () => {};
|
|
207
|
+
const recordEdit = vi.fn(
|
|
208
|
+
() =>
|
|
209
|
+
new Promise<void>((resolve) => {
|
|
210
|
+
finishRecord = resolve;
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
const { persist, params } = renderPersist({ editHistory: { recordEdit } });
|
|
214
|
+
|
|
215
|
+
const saving = persist(selection, OPERATIONS);
|
|
216
|
+
await vi.waitFor(() => expect(recordEdit).toHaveBeenCalledTimes(1));
|
|
217
|
+
expect(params.reloadPreview).not.toHaveBeenCalled();
|
|
218
|
+
finishRecord();
|
|
219
|
+
await saving;
|
|
220
|
+
|
|
221
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
222
|
+
expect(params.reloadPreview).toHaveBeenCalledTimes(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("writes prepared content after the patch and records it as the saved file", async () => {
|
|
226
|
+
stubServer(CHANGED);
|
|
227
|
+
const { persist, params } = renderPersist();
|
|
228
|
+
|
|
229
|
+
const outcome = await persist(selection, OPERATIONS, { prepareContent: () => PREPARED });
|
|
230
|
+
|
|
231
|
+
expect(params.writeProjectFile).toHaveBeenCalledWith("index.html", PREPARED, PATCHED);
|
|
232
|
+
expect(params.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
233
|
+
expect.objectContaining({ files: { "index.html": { before: SOURCE, after: PREPARED } } }),
|
|
234
|
+
);
|
|
235
|
+
expect(params.reloadPreview).toHaveBeenCalledTimes(1);
|
|
236
|
+
expect(outcome).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("a failed prepared write keeps the patch, reloads, then rejects", async () => {
|
|
240
|
+
stubServer(CHANGED);
|
|
241
|
+
const { persist, params } = renderPersist({
|
|
242
|
+
writeProjectFile: vi.fn(async () => {
|
|
243
|
+
throw new Error("disk full");
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const saving = persist(selection, OPERATIONS, { prepareContent: () => PREPARED });
|
|
248
|
+
|
|
249
|
+
await expect(saving).rejects.toBeInstanceOf(DomEditPersistPreparedWriteError);
|
|
250
|
+
expect(params.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
251
|
+
expect.objectContaining({ files: { "index.html": { before: SOURCE, after: PATCHED } } }),
|
|
252
|
+
);
|
|
253
|
+
expect(params.reloadPreview).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(params.showToast).toHaveBeenCalledWith(
|
|
255
|
+
"Saved, but couldn't finish updating index.html: disk full",
|
|
256
|
+
"error",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("a declined SDK cutover falls back to the server patch", async () => {
|
|
261
|
+
const fetchMock = stubServer(CHANGED);
|
|
262
|
+
const onTrySdkPersist = sdkResult({ status: "declined", reason: "unsupported" });
|
|
263
|
+
const { persist, params } = renderPersist({ onTrySdkPersist });
|
|
264
|
+
|
|
265
|
+
await persist(selection, OPERATIONS);
|
|
266
|
+
|
|
267
|
+
expect(onTrySdkPersist).toHaveBeenCalledTimes(1);
|
|
268
|
+
expect(patchPosts(fetchMock)).toHaveLength(1);
|
|
269
|
+
expect(params.editHistory.recordEdit).toHaveBeenCalledTimes(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("a failed SDK cutover rethrows without a server patch", async () => {
|
|
273
|
+
const fetchMock = stubServer(CHANGED);
|
|
274
|
+
const error = new Error("sdk broke");
|
|
275
|
+
const { persist } = renderPersist({ onTrySdkPersist: sdkResult({ status: "failed", error }) });
|
|
276
|
+
|
|
277
|
+
await expect(persist(selection, OPERATIONS)).rejects.toBe(error);
|
|
278
|
+
expect(patchPosts(fetchMock)).toHaveLength(0);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("an imported font or prepared content skips the SDK and goes to the server", async () => {
|
|
282
|
+
const fetchMock = stubServer(CHANGED);
|
|
283
|
+
const onTrySdkPersist = sdkResult({ status: "declined", reason: "unused" });
|
|
284
|
+
const { persist } = renderPersist({ onTrySdkPersist });
|
|
285
|
+
const importedFont = { family: "Inter", path: "fonts/inter.woff2", url: "/fonts/inter.woff2" };
|
|
286
|
+
|
|
287
|
+
await persist(selection, OPERATIONS, { importedFont });
|
|
288
|
+
await persist(selection, OPERATIONS, { prepareContent: (html) => html });
|
|
289
|
+
|
|
290
|
+
expect(onTrySdkPersist).not.toHaveBeenCalled();
|
|
291
|
+
const [[, fontInit]] = patchPosts(fetchMock);
|
|
292
|
+
expect(JSON.parse(String(fontInit?.body)).fontFaceCss).toContain("Inter");
|
|
293
|
+
expect(patchPosts(fetchMock)).toHaveLength(2);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("counts a save on the edited element's document until it settles", async () => {
|
|
297
|
+
stubServer(CHANGED);
|
|
298
|
+
const element = document.createElement("div");
|
|
299
|
+
const { persist } = renderPersist();
|
|
300
|
+
const before = studioManualEditSavesIn(document);
|
|
301
|
+
|
|
302
|
+
const saving = persist({ ...selection, element }, OPERATIONS);
|
|
303
|
+
expect(studioManualEditSavesIn(document)).toBe(before + 1);
|
|
304
|
+
await saving;
|
|
305
|
+
|
|
306
|
+
expect(studioManualEditSavesIn(document)).toBe(before + 2);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("does nothing when shouldSave says no", async () => {
|
|
310
|
+
const fetchMock = stubServer({ changed: true });
|
|
311
|
+
const { persist } = renderPersist();
|
|
312
|
+
|
|
313
|
+
await expect(persist(selection, OPERATIONS, { shouldSave: () => false })).resolves.toBe(
|
|
314
|
+
undefined,
|
|
315
|
+
);
|
|
316
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("refuses to save once the active project has changed", async () => {
|
|
320
|
+
stubServer({ changed: true });
|
|
321
|
+
const projectIdRef = { current: "p1" as string | null };
|
|
322
|
+
const { persist, params } = renderPersist({
|
|
323
|
+
projectIdRef,
|
|
324
|
+
queueDomEditSave: async (save) => {
|
|
325
|
+
projectIdRef.current = "p2";
|
|
326
|
+
return save();
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await expect(persist(selection, OPERATIONS)).rejects.toThrow(
|
|
331
|
+
"Active project changed before the edit could be saved",
|
|
332
|
+
);
|
|
333
|
+
expect(params.editHistory.recordEdit).not.toHaveBeenCalled();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { act, createElement, useRef } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
5
5
|
import {
|
|
6
6
|
useDomGeometryCommit,
|
|
7
7
|
usePlayerStore,
|
|
@@ -108,6 +108,10 @@ afterEach(() => {
|
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
describe("useDomGeometryCommit, from the package entry", () => {
|
|
111
|
+
it("takes the host's own preview reload, with no fallback", () => {
|
|
112
|
+
expectTypeOf<UseDomGeometryCommitOptions["reloadPreview"]>().toEqualTypeOf<() => void>();
|
|
113
|
+
});
|
|
114
|
+
|
|
111
115
|
it("plugs into DomEditOverlay and saves a move as one GSAP write and one undo step", async () => {
|
|
112
116
|
const mutations = stubServer();
|
|
113
117
|
const { element, recordEdit, hook, unmount } = renderHost();
|
|
@@ -26,8 +26,8 @@ import { useMountEffect } from "./useMountEffect";
|
|
|
26
26
|
* mounts; drain `waitForPendingSaves` before switching projects.
|
|
27
27
|
*/
|
|
28
28
|
export interface UseDomGeometryCommitOptions extends UseDomStyleCommitOptions {
|
|
29
|
-
/**
|
|
30
|
-
reloadPreview
|
|
29
|
+
/** Reloads the host's preview when a save cannot patch it in place. */
|
|
30
|
+
reloadPreview: () => void;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export interface DomGeometryCommits {
|
|
@@ -42,6 +42,7 @@ export interface DomGeometryCommits {
|
|
|
42
42
|
next: { width: number; height: number },
|
|
43
43
|
offset?: { x: number; y: number },
|
|
44
44
|
restore?: () => void,
|
|
45
|
+
route?: { plainTranslate: boolean },
|
|
45
46
|
) => Promise<DomEditCommitOutcome>;
|
|
46
47
|
commitRotation: (
|
|
47
48
|
selection: DomEditSelection,
|
|
@@ -70,17 +71,13 @@ export function useDomGeometryCommit({
|
|
|
70
71
|
projectIdRef.current = projectId;
|
|
71
72
|
const pending = useRef(new Set<Promise<GeometryCommitResult | undefined>>()).current;
|
|
72
73
|
const editHistory = useMemo(() => ({ recordEdit }), [recordEdit]);
|
|
73
|
-
const reload = useCallback(
|
|
74
|
-
() => (reloadPreview ? reloadPreview() : iframeRef.current?.contentWindow?.location.reload()),
|
|
75
|
-
[reloadPreview, iframeRef],
|
|
76
|
-
);
|
|
77
74
|
const { bump: bumpGsapCache } = useGsapCacheVersion();
|
|
78
75
|
const gsap = useGsapScriptCommits({
|
|
79
76
|
projectIdRef,
|
|
80
77
|
activeCompPath,
|
|
81
78
|
previewIframeRef: iframeRef,
|
|
82
79
|
editHistory,
|
|
83
|
-
reloadPreview
|
|
80
|
+
reloadPreview,
|
|
84
81
|
onCacheInvalidate: bumpGsapCache,
|
|
85
82
|
showToast,
|
|
86
83
|
writeProjectFile,
|
|
@@ -174,8 +171,8 @@ export function useDomGeometryCommit({
|
|
|
174
171
|
saved(() => handleGsapAwarePathOffsetCommit(selection, next, modifiers)),
|
|
175
172
|
commitGroupPathOffset: (updates) =>
|
|
176
173
|
saved(() => handleGsapAwareGroupPathOffsetCommit(updates)),
|
|
177
|
-
commitBoxSize: (selection, next, offset, restore) =>
|
|
178
|
-
saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore), restore),
|
|
174
|
+
commitBoxSize: (selection, next, offset, restore, route) =>
|
|
175
|
+
saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore, route), restore),
|
|
179
176
|
commitRotation: (selection, next) =>
|
|
180
177
|
saved(() => handleGsapAwareRotationCommit(selection, next)),
|
|
181
178
|
waitForPendingSaves: () => Promise.allSettled([...pending]).then(noop),
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
1
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
5
|
import type { TimelineElement } from "../player";
|
|
3
|
-
import { requestFreezeFrame } from "./useFreezeFrame";
|
|
6
|
+
import { requestFreezeFrame, useFreezeFrame } from "./useFreezeFrame";
|
|
4
7
|
|
|
5
8
|
afterEach(() => vi.unstubAllGlobals());
|
|
6
9
|
|
|
@@ -29,14 +32,15 @@ function stubFetch(freezeResponse: { status: number; body: unknown }) {
|
|
|
29
32
|
|
|
30
33
|
describe("requestFreezeFrame", () => {
|
|
31
34
|
it("posts the clip target, file version and an authored-time playhead", async () => {
|
|
32
|
-
const
|
|
35
|
+
const frozen = { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/t.png" };
|
|
36
|
+
const calls = stubFetch({ status: 200, body: frozen });
|
|
33
37
|
const result = await requestFreezeFrame({
|
|
34
38
|
projectId: "p",
|
|
35
39
|
path: "scene.html",
|
|
36
40
|
element,
|
|
37
41
|
playhead: 7.5,
|
|
38
42
|
});
|
|
39
|
-
expect(result).toEqual(
|
|
43
|
+
expect(result).toEqual(frozen);
|
|
40
44
|
const post = calls[1];
|
|
41
45
|
expect(post?.url).toContain("/file-mutations/freeze-frame");
|
|
42
46
|
expect(post?.body).toMatchObject({
|
|
@@ -54,3 +58,35 @@ describe("requestFreezeFrame", () => {
|
|
|
54
58
|
).rejects.toThrow("Move the playhead inside a video clip to freeze");
|
|
55
59
|
});
|
|
56
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("useFreezeFrame", () => {
|
|
63
|
+
it("records the still it made with the edit, so Undo removes it", async () => {
|
|
64
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
65
|
+
stubFetch({
|
|
66
|
+
status: 200,
|
|
67
|
+
body: { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/talk.png" },
|
|
68
|
+
});
|
|
69
|
+
const recordEdit = vi.fn(async () => {});
|
|
70
|
+
let freeze!: ReturnType<typeof useFreezeFrame>;
|
|
71
|
+
function Harness() {
|
|
72
|
+
freeze = useFreezeFrame({
|
|
73
|
+
projectId: "p",
|
|
74
|
+
activeCompPath: "index.html",
|
|
75
|
+
showToast: () => {},
|
|
76
|
+
writeProjectFile: async () => {},
|
|
77
|
+
recordEdit,
|
|
78
|
+
reloadPreview: () => {},
|
|
79
|
+
});
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const root = createRoot(document.createElement("div"));
|
|
83
|
+
await act(async () => root.render(createElement(Harness)));
|
|
84
|
+
await act(() => freeze({ ...element, sourceFile: "index.html" }, 7.5));
|
|
85
|
+
expect(recordEdit).toHaveBeenCalledWith({
|
|
86
|
+
label: "Freeze frame",
|
|
87
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
88
|
+
created: ["assets/freeze/talk.png"],
|
|
89
|
+
});
|
|
90
|
+
act(() => root.unmount());
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -14,6 +14,7 @@ interface FreezeFrameResponse {
|
|
|
14
14
|
before: string;
|
|
15
15
|
after: string;
|
|
16
16
|
version: string;
|
|
17
|
+
stillPath: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
|
|
@@ -22,7 +23,8 @@ function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
|
|
|
22
23
|
return (
|
|
23
24
|
typeof body.before === "string" &&
|
|
24
25
|
typeof body.after === "string" &&
|
|
25
|
-
typeof body.version === "string"
|
|
26
|
+
typeof body.version === "string" &&
|
|
27
|
+
typeof body.stillPath === "string"
|
|
26
28
|
);
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -109,6 +111,7 @@ export function useFreezeFrame(options: UseFreezeFrameOptions) {
|
|
|
109
111
|
await opts.recordEdit({
|
|
110
112
|
label: "Freeze frame",
|
|
111
113
|
files: { [path]: { before: result.before, after: result.after } },
|
|
114
|
+
created: [result.stillPath],
|
|
112
115
|
});
|
|
113
116
|
opts.observeProjectFileVersion?.(path, result.version);
|
|
114
117
|
});
|