@hyperframes/studio 0.8.116 → 0.8.117
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-5fcgSstJ.js} +1 -1
- package/dist/assets/{index-DYNaXPVf.js → index-C5qKdpgl.js} +178 -176
- package/dist/assets/{index-BWHt0-ir.js → index-XJSawwYe.js} +1 -1
- package/dist/assets/{index-Dr3hNpvS.js → index-tg2LXUeu.js} +1 -1
- package/dist/index.d.ts +28 -19
- package/dist/index.html +1 -1
- package/dist/index.js +77 -64
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +1 -0
- 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/hooks/gsapRuntimeKeyframes.ts +16 -3
- package/src/hooks/gsapScriptCommitTypes.ts +1 -1
- package/src/hooks/plainRotation.ts +5 -1
- 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/utils/gsapSoftReload.test.ts +39 -0
- package/src/utils/gsapSoftReload.ts +7 -11
- package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
- package/src/utils/softReloadTargets.ts +4 -3
- package/src/utils/studioFileHistory.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.117",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/studio-server": "0.8.
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.117",
|
|
64
|
+
"@hyperframes/parsers": "0.8.117",
|
|
65
|
+
"@hyperframes/sdk": "0.8.117",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.117",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.117",
|
|
68
|
+
"@hyperframes/player": "0.8.117"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.117"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -94,6 +94,7 @@ export interface DomEditOverlayProps {
|
|
|
94
94
|
next: { width: number; height: number },
|
|
95
95
|
offset?: { x: number; y: number },
|
|
96
96
|
restore?: () => void,
|
|
97
|
+
route?: { plainTranslate: boolean },
|
|
97
98
|
) => Promise<unknown> | void;
|
|
98
99
|
onRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<unknown> | void;
|
|
99
100
|
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
@@ -275,6 +275,7 @@ export type UseDomEditOverlayGesturesOptions = {
|
|
|
275
275
|
n: { width: number; height: number },
|
|
276
276
|
offset?: { x: number; y: number },
|
|
277
277
|
restore?: () => void,
|
|
278
|
+
route?: { plainTranslate: boolean },
|
|
278
279
|
) => Promise<unknown> | void
|
|
279
280
|
>;
|
|
280
281
|
onRotationCommitRef: RefObject<
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
type UseDomEditOverlayGesturesOptions,
|
|
33
33
|
} from "./domEditOverlayGestures";
|
|
34
34
|
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
|
|
35
|
-
import {
|
|
35
|
+
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
36
36
|
import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
|
|
37
37
|
import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
|
|
38
38
|
|
|
@@ -234,7 +234,7 @@ export function startGesture(
|
|
|
234
234
|
try {
|
|
235
235
|
// Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
|
|
236
236
|
// move, never asks GSAP: reading a property makes it bake the CSS into its transform.
|
|
237
|
-
const plain = !!pathOffsetMember?.plainTranslate ||
|
|
237
|
+
const plain = !!pathOffsetMember?.plainTranslate || editsPlainCss(sel.element, "rotate");
|
|
238
238
|
const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
|
|
239
239
|
const rotation = { angle: readRotationBase(sel.element, plain) };
|
|
240
240
|
const overlayBounds = overlayEl?.getBoundingClientRect();
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type StudioPathOffsetSnapshot,
|
|
12
12
|
} from "./manualEdits";
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
|
-
import {
|
|
14
|
+
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
15
|
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
16
|
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
|
|
17
17
|
|
|
@@ -318,8 +318,8 @@ export function createManualOffsetDragMember(input: {
|
|
|
318
318
|
rect: ManualOffsetDragRect;
|
|
319
319
|
gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
|
|
320
320
|
}): ManualOffsetDragMemberResult {
|
|
321
|
-
const
|
|
322
|
-
|
|
321
|
+
const plainTranslate =
|
|
322
|
+
!!input.gesture && editsPlainCss(input.element, input.gesture === "resize" ? "resize" : "move");
|
|
323
323
|
// The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
|
|
324
324
|
const initialOffset = plainTranslate
|
|
325
325
|
? readTranslatePx(input.element)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { roundTo3 } from "../../utils/rounding";
|
|
2
|
-
import {
|
|
2
|
+
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
3
|
import { readCssRotation } from "../../hooks/draggedGsapPosition";
|
|
4
4
|
import { readStudioPathOffset, readStudioRotation, styleUsesStudioOffset } from "./manualEditsDom";
|
|
5
5
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
@@ -142,10 +142,12 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
/** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
|
|
145
|
-
export function readMoveOffset(el: HTMLElement, plainTranslate =
|
|
145
|
+
export function readMoveOffset(el: HTMLElement, plainTranslate = editsPlainCss(el, "move")): Point {
|
|
146
146
|
return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
export function readShownRotation(el: HTMLElement): { angle: number } {
|
|
150
|
-
return
|
|
150
|
+
return editsPlainCss(el, "rotate")
|
|
151
|
+
? { angle: roundTo3(readCssRotation(el)) }
|
|
152
|
+
: readStudioRotation(el);
|
|
151
153
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
playbackStartAttributeForElement,
|
|
10
10
|
readPlaybackStartAttributes,
|
|
11
11
|
} from "../../player/lib/timelineElementHelpers";
|
|
12
|
-
import {
|
|
12
|
+
import { editsPlainCss, GSAP_TRANSFORM_KEYS } from "../../hooks/gsapRuntimeKeyframes";
|
|
13
13
|
|
|
14
14
|
export type {
|
|
15
15
|
BackgroundRemovalProgress,
|
|
@@ -496,7 +496,7 @@ export function readGsapRuntimeValuesForPanel(
|
|
|
496
496
|
const el = findPreviewNode(iframe.contentDocument, element);
|
|
497
497
|
if (!el) return null;
|
|
498
498
|
const propKeys = collectPanelPropKeys(gsapAnimations);
|
|
499
|
-
const readsTransform =
|
|
499
|
+
const readsTransform = !editsPlainCss(el, "move");
|
|
500
500
|
const result: Record<string, number> = {};
|
|
501
501
|
for (const prop of propKeys) {
|
|
502
502
|
if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
2
2
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
3
3
|
import { readStudioBoxSize } from "./manualEdits";
|
|
4
4
|
import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
@@ -75,7 +75,7 @@ export function createTransformCommitHandlers({
|
|
|
75
75
|
)
|
|
76
76
|
)
|
|
77
77
|
return;
|
|
78
|
-
const plainTranslate =
|
|
78
|
+
const plainTranslate = editsPlainCss(element.element, "move");
|
|
79
79
|
const current = readMoveOffset(element.element, plainTranslate);
|
|
80
80
|
if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
|
|
81
81
|
return void showToast?.(UNREADABLE_TRANSLATE);
|
|
@@ -51,7 +51,8 @@ export interface CssRotationTarget {
|
|
|
51
51
|
inline: boolean;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export type
|
|
54
|
+
export type PressChoseGsap = null;
|
|
55
|
+
export type RotationCommit = { angle: number; plain?: CssRotationTarget | PressChoseGsap };
|
|
55
56
|
|
|
56
57
|
const TRANSLATE = /^translate(?:3d|X|Y|Z)?\(/i;
|
|
57
58
|
const OWN_TURN = /^rotate\(\s*-?[\d.]+(?:e[+-]?\d+)?deg\s*\)$/i;
|
|
@@ -442,7 +442,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
442
442
|
}
|
|
443
443
|
// Hold the final angle while the commit lands.
|
|
444
444
|
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
|
|
445
|
-
const commit =
|
|
445
|
+
const commit = { ...finalRotation, plain: g.plainRotation };
|
|
446
446
|
void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
|
|
447
447
|
.then((result) => trackPreviewEditResult("rotate", "drag", result))
|
|
448
448
|
.catch((error) => {
|
|
@@ -536,7 +536,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
536
536
|
if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
|
|
537
537
|
};
|
|
538
538
|
void Promise.resolve(
|
|
539
|
-
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
|
|
539
|
+
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member),
|
|
540
540
|
)
|
|
541
541
|
.then((result) => trackPreviewEditResult("resize", "drag", result))
|
|
542
542
|
.catch((error) => {
|
|
@@ -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, {
|
|
@@ -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),
|