Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 48 additions & 27 deletions packages/studio/src/components/editor/useDomEditNudge.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { trackStudioEvent } from "../../utils/studioTelemetry";
// @vitest-environment happy-dom
import React, { act } from "react";
Expand All @@ -7,7 +8,11 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
import { __resetForTests } from "../../utils/canvasNudgeGate";
import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits";
import {
flushStudioPendingEdits,
hasStudioPendingEdits,
trackedStudioEdit,
} from "../../utils/studioPendingEdits";
import type { DomEditSelection } from "./domEditing";
import type { OverlayRect } from "./domEditOverlayGeometry";

Expand Down Expand Up @@ -392,23 +397,32 @@ describe("useDomEditNudge pauses playback before it snapshots the timelines", ()
});
});

/** A burst-ready Harness on a fresh element, under fake timers. */
function mountBurstHarness(
id: string,
onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"],
) {
__resetForTests();
vi.useFakeTimers();
const root = createRoot(document.body.appendChild(document.createElement("div")));
const element = document.body.appendChild(document.createElement("div"));
element.id = id;
act(() => {
root.render(
React.createElement(Harness, {
selection: makeSelection("Dot", element),
onPathOffsetCommit,
}),
);
});
return root;
}

describe("useDomEditNudge — undo right after a burst", () => {
it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
__resetForTests();
vi.useFakeTimers();
const root = createRoot(document.body.appendChild(document.createElement("div")));
const element = document.body.appendChild(document.createElement("div"));
element.id = "dot-undo";
let saved!: () => void;
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
act(() => {
root.render(
React.createElement(Harness, {
selection: makeSelection("Dot", element),
onPathOffsetCommit: commit,
}),
);
});
const root = mountBurstHarness("dot-undo", commit);
act(() => dispatchArrowRight());

let drained = false;
Expand All @@ -424,21 +438,9 @@ describe("useDomEditNudge — undo right after a burst", () => {
});

it("counts the burst as a pending edit from its first key until its save lands", async () => {
__resetForTests();
vi.useFakeTimers();
const root = createRoot(document.body.appendChild(document.createElement("div")));
const element = document.body.appendChild(document.createElement("div"));
element.id = "dot-pending";
let saved!: () => void;
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
act(() => {
root.render(
React.createElement(Harness, {
selection: makeSelection("Dot", element),
onPathOffsetCommit: commit,
}),
);
});
const root = mountBurstHarness("dot-pending", commit);
expect(hasStudioPendingEdits()).toBe(false);
act(() => dispatchArrowRight());
expect(hasStudioPendingEdits()).toBe(true);
Expand All @@ -452,6 +454,25 @@ describe("useDomEditNudge — undo right after a burst", () => {
});
});

describe("useDomEditNudge — a Design-panel edit during a burst", () => {
it("commits the burst before the panel edit, so one undo takes back the panel edit", async () => {
const order: string[] = [];
const root = mountBurstHarness("dot-panel", async () => void order.push("nudge"));
try {
act(() => dispatchArrowRight());
const panelEdit = trackedStudioEdit(async () => void order.push("width"), {
afterOlderSaves: true,
})();
vi.useRealTimers();
await panelEdit;
expect(order).toEqual(["nudge", "width"]);
} finally {
vi.useRealTimers();
act(() => root.unmount());
}
});
});

describe("useDomEditNudge — a commit that throws", () => {
it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
__resetForTests();
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/components/ui/cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* invents (`text-step-*`, `ctl`/`ctl-sm`/`ctl-lg`) so overrides replace, not stack.
*/

import type { CnFunction } from "cn";
import { createCn } from "cn/config";

/** `text-step-11`, `text-step-9`, … — the type scale from `theme.css`. */
Expand All @@ -11,7 +12,7 @@ const isTypeStep = (value: string) => /^step-\d+$/.test(value);
/** `ctl`, `ctl-sm`, `ctl-lg` — the control heights from `theme.css`. */
const isControlSize = (value: string) => /^ctl(-sm|-lg)?$/.test(value);

export const cn = createCn({
export const cn: CnFunction = createCn({
extend: {
classGroups: {
"font-size": [{ text: [isTypeStep] }],
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/contexts/DomEditContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue {
const tracked: Record<string, unknown> = { ...actions };
for (const key of Object.keys(SAVES_AN_EDIT) as Array<keyof typeof SAVES_AN_EDIT>) {
if (!SAVES_AN_EDIT[key]) continue;
tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown);
tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown, {
afterOlderSaves: true,
});
}
return tracked as unknown as DomEditActionsValue;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/hooks/gsapRuntimePatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type RuntimeTween,
type RuntimeTimeline,
} from "./gsapRuntimeKeyframes";
import { recordLiveSet } from "../utils/softReloadTargets";

/** Value-only channels a `tl.set(...)` patch may touch. */
export interface SetPatchProps {
Expand All @@ -34,6 +35,8 @@ export interface SetPatchProps {
scaleY?: number;
scale?: number;
opacity?: number;
width?: number;
height?: number;
}

/** A single keyframe step's numeric channels (the GSAP array-keyframe form). */
Expand Down Expand Up @@ -101,6 +104,7 @@ function applyGlobalSet(
const numeric = finiteNumericProps(props);
if (!gsapLib?.set || !el || !numeric) return false;
gsapLib.set(el, numeric);
recordLiveSet(el, numeric);
return true;
} catch {
return false;
Expand Down
21 changes: 21 additions & 0 deletions packages/studio/src/hooks/useDomGeometryCommits.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,27 @@ describe("Reset after a plain move puts the author's translate back", () => {
});
});

describe("useDomGeometryCommits undo steps", () => {
it("never merges a resize with the next size edit of the same element", async () => {
const element = document.createElement("div");
element.id = "box";
document.body.append(element);
const selection = { id: "box", selector: "#box", element } as unknown as DomEditSelection;
const commitPositionPatchToHtml = vi
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
.mockResolvedValue(undefined);
const { commits, unmount } = mountCommits(commitPositionPatchToHtml);

await commits().handleDomBoxSizeCommit(selection, { width: 418, height: 200 });
await commits().handleDomBoxSizeCommit(selection, { width: 280, height: 200 });

const [resize, field] = commitPositionPatchToHtml.mock.calls.map(([, , options]) => options);
expect(resize!.coalesceKey).not.toBe(field!.coalesceKey);
unmount();
element.remove();
});
});

describe("useDomGeometryCommits resize of a cropped element", () => {
it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
const element = withInlineLayoutBox(document.createElement("div"));
Expand Down
7 changes: 6 additions & 1 deletion packages/studio/src/hooks/useDomGeometryCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
} from "../components/editor/manualEditsDomPatches";
import type { PatchOperation } from "../utils/sourcePatcher";

let boxSizeCommitCounter = 0;

// ── Hook ──

export interface UseDomGeometryCommitsParams {
Expand Down Expand Up @@ -90,7 +92,10 @@ export function useDomGeometryCommits({
label: "Resize layer box",
...(undoKey
? { coalesceKey: undoKey, coalesceMs: Number.POSITIVE_INFINITY, deferRender: true }
: { coalesceKey: `box-size:${getDomEditTargetKey(selection)}` }),
: {
coalesceKey: `box-size:${++boxSizeCommitCounter}`,
coalesceMs: Number.POSITIVE_INFINITY,
}),
}).catch((error) => {
restoreStudioBoxSize(element, beforeSize);
if (offset) restoreStudioPathOffset(element, beforeOffset);
Expand Down
112 changes: 98 additions & 14 deletions packages/studio/src/utils/gsapSoftReload.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// fallow-ignore-file code-duplication
// @vitest-environment happy-dom

import { patchRuntimeTweenInPlace } from "../hooks/gsapRuntimePatch";
import { afterEach, describe, it, expect, vi } from "vitest";
import {
readNestedFiles,
Expand Down Expand Up @@ -70,6 +72,29 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
};
}

/** A mock iframe whose document holds one GSAP script and one composition root; `byId` answers id selectors. */
function iframeWithComposition(script: string, root: Element, byId: Record<string, Element> = {}) {
const scriptEl = document.createElement("script");
scriptEl.textContent = script;
const container = document.createElement("div");
container.appendChild(scriptEl);
const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
querySelectorAll: (sel: string) =>
sel === "script:not([src])"
? [scriptEl]
: sel.includes("composition-id")
? [root]
: byId[sel]
? [byId[sel]]
: [],
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
return iframe;
}

describe("applySoftReload", () => {
it('returns "cannot-soft-reload" when iframe is null', () => {
expect(applySoftReload(null, SCRIPT_TEXT)).toBe("cannot-soft-reload");
Expand Down Expand Up @@ -127,30 +152,51 @@ describe("applySoftReload", () => {
orphan.style.cssText = "left: 1240px; top: 200px; transform: translate(449px, 0px)";
Object.assign(orphan, { _gsap: {} }); // GSAP cache marker (set by gsap.set)

const scriptEl = document.createElement("script");
scriptEl.textContent = 'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });';
const container = document.createElement("div");
container.appendChild(scriptEl);

const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.appendChild(orphan);

const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
querySelectorAll: (sel: string) =>
sel === "script:not([src])" ? [scriptEl] : sel.includes("composition-id") ? [root] : [],
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
const iframe = iframeWithComposition(
'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });',
root,
);

applySoftReload(iframe, SCRIPT_TEXT);

expect(orphan.style.transform).toBe(""); // stale GSAP transform stripped
expect(orphan.style.left).toBe("1240px"); // authored CSS base preserved
});

it("clears what a standalone gsap.set wrote once the new script no longer sets it", () => {
// An undo of a Design-panel W edit on an animated box removes its gsap.set width.
const target = document.createElement("div");
target.id = "target";
target.style.cssText = "left: 10px; width: 300px";
Object.assign(target, { _gsap: {} });
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.appendChild(target);
const iframe = iframeWithComposition(
`window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#target", { x: 100 });
gsap.set("#target", { width: 300 });
window.__timelines["root"] = tl;`,
root,
{ "#target": target },
);
const restored = `window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#target", { x: 100 });
window.__timelines["root"] = tl;`;

applySoftReload(iframe, restored, {
authoredHtml: `<html><body><div data-composition-id="root"><div id="target" style="left: 10px"></div></div><script>${restored}</script></body></html>`,
});

expect(target.style.width).toBe("");
expect(target.style.left).toBe("10px");
});

it("wraps execution in __hfSuppressSceneMutations when available", () => {
let suppressionCalled = false;
const { iframe } = buildMockIframe({
Expand Down Expand Up @@ -917,3 +963,41 @@ describe("applySoftReload over a composition's own markup", () => {
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
});
});

describe("a gsap.set a live patch applied", () => {
it("is cleared by the next soft reload once the new script no longer sets it, and only once", () => {
const markup = `<div data-composition-id="root"><div id="a" style="left: 10px"></div></div>`;
const doc = document.implementation.createHTMLDocument("");
doc.body.innerHTML = `${markup}<script>window.__timelines["root"]=gsap.timeline();</script>`;
const set = (target: HTMLElement | HTMLElement[], vars: Record<string, unknown>) => {
for (const el of [target].flat()) {
if (vars.clearProps) el.removeAttribute("style");
else for (const [k, v] of Object.entries(vars)) el.style.setProperty(k, `${v}px`);
}
};
const iframe = {
contentDocument: doc,
contentWindow: {
gsap: { timeline: () => {}, set },
__hfForceTimelineRebind: () => {},
__timelines: {},
__player: { getTime: () => 0, seek: () => {} },
},
} as unknown as HTMLIFrameElement;
const el = doc.getElementById("a")!;
const reload = () => {
const script = `window.__timelines["root"]=gsap.timeline();gsap.set("#a",{width:300});`;
applySoftReload(iframe, script, { authoredHtml: `${markup}<script>${script}</script>` });
};

// W 300, then H 200, then a commit that removes height from the set.
patchRuntimeTweenInPlace(iframe, "#a", { kind: "global-set", props: { width: 300 } });
patchRuntimeTweenInPlace(iframe, "#a", { kind: "global-set", props: { height: 200 } });
reload();
expect([el.style.height, el.style.left]).toEqual(["", "10px"]);

el.style.height = "50px";
reload();
expect(el.style.height).toBe("50px");
});
});
16 changes: 14 additions & 2 deletions packages/studio/src/utils/gsapSoftReload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-gradin
import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
import { findAuthoredElement } from "./authoredSource";
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
import {
authoringFile,
collectResetTargets,
compositionFile,
fileDocs,
forgetLiveSets,
} from "./softReloadTargets";

type IframeWindow = Window & {
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
Expand Down Expand Up @@ -370,7 +376,12 @@ export function applySoftReload(
// fallow-ignore-next-line complexity
const doReload = () => {
const timelines = win.__timelines;
const targets = collectResetTargets(win, doc, targetKeys);
const targets = collectResetTargets(
win,
doc,
targetKeys,
staleScripts.map((script) => script.textContent ?? ""),
);

// Kill ONLY the target composition's timeline(s) — leaving every other
// composition's timeline (and its children on the global timeline) intact.
Expand Down Expand Up @@ -423,6 +434,7 @@ export function applySoftReload(
}
}
}
forgetLiveSets(targets);

for (const script of staleScripts) script.remove();

Expand Down
Loading
Loading