mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): undo takes back the last edit, even right after a resize or a nudge (#5048)
* fix(studio): a size edit right after a resize is its own undo step * fix(studio): a nudge still saving is recorded before the next edit * fix(studio): published types name cn by its package, not a node_modules path * fix(studio): drop baseUrl so declarations never name a node_modules path * fix(studio): an edit started inside the save of another edit never flushes * fix(studio): an edit made while a nudge is still saving writes after it * fix(studio): a later edit never overtakes one still waiting on a nudge save * fix(studio): undoing a gsap.set clears what it applied from the preview * fix(studio): an edit never waits on itself behind a nudge save * fix(studio): a second nudge burst saves when its commit runs behind the first * fix(studio): undoing a W on an animated box clears the width it set live * fix(studio): any GSAP edit that drops a live-applied set clears it from the preview * fix(studio): a soft reload clears exactly the set props a live patch applied * fix(studio): name width and height as set-patch props
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
// @vitest-environment happy-dom
|
||||
import React, { act } from "react";
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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`. */
|
||||
@@ -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] }],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -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). */
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
} from "../components/editor/manualEditsDomPatches";
|
||||
import type { PatchOperation } from "../utils/sourcePatcher";
|
||||
|
||||
let boxSizeCommitCounter = 0;
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export interface UseDomGeometryCommitsParams {
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -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");
|
||||
@@ -127,23 +152,13 @@ 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);
|
||||
|
||||
@@ -151,6 +166,37 @@ describe("applySoftReload", () => {
|
||||
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({
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }>;
|
||||
@@ -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.
|
||||
@@ -423,6 +434,7 @@ export function applySoftReload(
|
||||
}
|
||||
}
|
||||
}
|
||||
forgetLiveSets(targets);
|
||||
|
||||
for (const script of staleScripts) script.remove();
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
diffSoftReloadableRestore,
|
||||
showRestoreInPlace,
|
||||
} from "./gsapUndoRestore";
|
||||
import { recordLiveSet } from "./softReloadTargets";
|
||||
import { applyPatch } from "./sourcePatcher";
|
||||
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
|
||||
import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
|
||||
@@ -215,10 +216,16 @@ describe("applyUndoRestoreToPreview", () => {
|
||||
describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
|
||||
const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
|
||||
const root = (body: string) => `<div data-composition-id="root">${body}</div>`;
|
||||
const undoScriptEdit = (live: string, authored: string, edit: string) => {
|
||||
const undoScriptEdit = (
|
||||
live: string,
|
||||
authored: string,
|
||||
edit: string,
|
||||
patchedLive?: (doc: Document) => void,
|
||||
) => {
|
||||
const { iframe, contentWindow, doc } = buildLiveIframe(
|
||||
`${root(live)}<script>${script(edit)}</script>`,
|
||||
`${root(live)}<script>${script(patchedLive ? "" : edit)}</script>`,
|
||||
);
|
||||
patchedLive?.(doc);
|
||||
const clearProps = (targets: HTMLElement[]) =>
|
||||
targets.forEach((t) => t.removeAttribute("style"));
|
||||
Object.assign(contentWindow.gsap, { set: clearProps });
|
||||
@@ -243,6 +250,18 @@ describe("applyUndoRestoreToPreview", () => {
|
||||
expect(doc.getElementById("a")!.getAttribute("style")).toBe("width: 300px");
|
||||
});
|
||||
|
||||
it("drops the width a W edit set live, though the preview never ran its gsap.set", () => {
|
||||
// The Design panel applies W to the live element and writes the set only to the file.
|
||||
const doc = undoScriptEdit(
|
||||
`<div id="a" style="left: 10px; width: 300px">t</div>`,
|
||||
`<div id="a" style="left: 10px">t</div>`,
|
||||
`gsap.set("#a",{width:300});`,
|
||||
(live) => recordLiveSet(live.getElementById("a")!, { width: 300 }),
|
||||
);
|
||||
const { style } = doc.getElementById("a")!;
|
||||
expect([style.width, style.left]).toEqual(["", "10px"]);
|
||||
});
|
||||
|
||||
it("keeps the authored inline rotation of an element GSAP moved", () => {
|
||||
const doc = undoScriptEdit(
|
||||
`<div id="r" style="left: 10px; transform: translate(116px, 72px) rotate(20deg)">t</div>`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each.
|
||||
import { parseGsapScript } from "@hyperframes/parsers/gsap-parser";
|
||||
import { parseSavedSource } from "./authoredSource";
|
||||
import { elementTargets } from "./elementGsap";
|
||||
|
||||
@@ -49,6 +50,7 @@ export function collectResetTargets(
|
||||
win: { __timelines?: Record<string, unknown> },
|
||||
doc: Document,
|
||||
targetKeys: string[],
|
||||
outgoingScripts: readonly string[] = [],
|
||||
): Map<Element, Set<string>> {
|
||||
const targets = new Map<Element, Set<string>>();
|
||||
const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
|
||||
@@ -61,6 +63,8 @@ export function collectResetTargets(
|
||||
} catch {}
|
||||
}
|
||||
sweepHeldElements(doc, targetKeys, others, targets);
|
||||
addLiveSetTargets(doc, targetKeys, targets);
|
||||
for (const script of outgoingScripts) addStandaloneSetTargets(doc, script, targets);
|
||||
return targets;
|
||||
}
|
||||
|
||||
@@ -105,6 +109,58 @@ function sweepHeldElements(
|
||||
}
|
||||
}
|
||||
|
||||
// What a live patch set on each element since its last reload; no script in the preview ran it.
|
||||
const liveSets = new WeakMap<Document, Map<Element, Set<string>>>();
|
||||
|
||||
export function recordLiveSet(el: Element, vars: Record<string, unknown>): void {
|
||||
const byElement = liveSets.get(el.ownerDocument) ?? new Map<Element, Set<string>>();
|
||||
liveSets.set(el.ownerDocument, byElement);
|
||||
const props = byElement.get(el) ?? new Set<string>();
|
||||
for (const prop of tweenedProps(vars)) props.add(prop);
|
||||
byElement.set(el, props);
|
||||
}
|
||||
|
||||
function addLiveSetTargets(
|
||||
doc: Document,
|
||||
targetKeys: string[],
|
||||
targets: Map<Element, Set<string>>,
|
||||
): void {
|
||||
const roots = new Set<Element | null | undefined>(targetKeys.map((k) => compositionRoot(doc, k)));
|
||||
for (const [el, props] of liveSets.get(doc) ?? []) {
|
||||
if (!roots.has(el) && !roots.has(el.parentElement?.closest("[data-composition-id]"))) continue;
|
||||
const seen = targets.get(el) ?? new Set<string>();
|
||||
for (const prop of props) seen.add(prop);
|
||||
targets.set(el, seen);
|
||||
}
|
||||
}
|
||||
|
||||
export function forgetLiveSets(reset: Map<Element, unknown>): void {
|
||||
for (const el of reset.keys()) liveSets.get(el.ownerDocument)?.delete(el);
|
||||
}
|
||||
|
||||
/** Adds what each standalone `gsap.set` in the outgoing script wrote, which no timeline child records. */
|
||||
function addStandaloneSetTargets(
|
||||
doc: Document,
|
||||
outgoingScript: string,
|
||||
targets: Map<Element, Set<string>>,
|
||||
): void {
|
||||
if (!outgoingScript.includes("gsap.set(")) return;
|
||||
for (const set of parseGsapScript(outgoingScript).animations) {
|
||||
if (set.method !== "set" || !set.global) continue;
|
||||
let els: Element[];
|
||||
try {
|
||||
els = [...doc.querySelectorAll(set.targetSelector)];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const el of els) {
|
||||
const seen = targets.get(el) ?? new Set<string>();
|
||||
for (const prop of tweenedProps(set.properties)) seen.add(prop);
|
||||
targets.set(el, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each file a reset element may be written in, parsed at most once per reload: the reloaded
|
||||
// composition's own file is the one just written, any other comes from the caller.
|
||||
export function fileDocs(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
beginStudioPendingEdit,
|
||||
flushStudioPendingEdits,
|
||||
hasStudioPendingEdits,
|
||||
isStudioEditSaving,
|
||||
paintBackNewestStudioPendingEdit,
|
||||
trackStudioPendingEdit,
|
||||
trackedStudioEdit,
|
||||
@@ -199,6 +200,128 @@ describe("a drain that meets a conflict", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a new edit after a debounced one", () => {
|
||||
it("commits the debounced edit first, so history records them in the order they were made", () => {
|
||||
const order: string[] = [];
|
||||
const remove = addStudioPendingEditFlushListener(() => void order.push("nudge"));
|
||||
trackedStudioEdit(() => void order.push("panel"))();
|
||||
const drag = beginStudioPendingEdit(null);
|
||||
drag.adopt(() => void order.push("drag"));
|
||||
drag.settle();
|
||||
remove();
|
||||
expect(order).toEqual(["nudge", "panel", "nudge", "drag"]);
|
||||
});
|
||||
it("starts a new edit only after a flushed save that was still waiting has written", async () => {
|
||||
const order: string[] = [];
|
||||
let fetched!: () => void;
|
||||
// A nudge on an animated layer: its save waits on the animation fetch before it writes.
|
||||
const remove = addStudioPendingEditFlushListener(async () => {
|
||||
await new Promise<void>((resolve) => (fetched = resolve));
|
||||
order.push("nudge write");
|
||||
});
|
||||
const panelEdit = trackedStudioEdit(async () => void order.push("width write"), {
|
||||
afterOlderSaves: true,
|
||||
})();
|
||||
fetched();
|
||||
await panelEdit;
|
||||
remove();
|
||||
expect(order).toEqual(["nudge write", "width write"]);
|
||||
});
|
||||
|
||||
it("keeps every later edit behind one that waits on a flushed save, so the newest value lands last", async () => {
|
||||
const order: string[] = [];
|
||||
let fetched!: () => void;
|
||||
let saving = false;
|
||||
const remove = addStudioPendingEditFlushListener(() => {
|
||||
if (saving) return undefined;
|
||||
saving = true;
|
||||
return new Promise<void>((resolve) => (fetched = resolve)).then(
|
||||
() => void order.push("nudge"),
|
||||
);
|
||||
});
|
||||
const userEdit = (body: () => unknown) => trackedStudioEdit(body, { afterOlderSaves: true });
|
||||
const first = userEdit(async () => void order.push("W 200"))();
|
||||
const second = userEdit(async () => void order.push("W 300"))();
|
||||
const drag = beginStudioPendingEdit(null);
|
||||
const dragged = drag.adopt(() => Promise.resolve().then(() => void order.push("resize")));
|
||||
fetched();
|
||||
await Promise.all([first, second, dragged]);
|
||||
drag.settle();
|
||||
remove();
|
||||
expect(order).toEqual(["nudge", "W 200", "W 300", "resize"]);
|
||||
});
|
||||
|
||||
it("never waits on itself: a deferred edit or a flushed save may call a wrapped edit", async () => {
|
||||
let fetched!: () => void;
|
||||
let saving = false;
|
||||
const internal = trackedStudioEdit(async () => undefined);
|
||||
// The flushed save calls an internal wrapped commit after its fetch, as an animated nudge does.
|
||||
const remove = addStudioPendingEditFlushListener(() => {
|
||||
if (saving) return undefined;
|
||||
saving = true;
|
||||
return new Promise<void>((resolve) => (fetched = resolve)).then(() => internal());
|
||||
});
|
||||
const userEdit = (body: () => unknown) => trackedStudioEdit(body, { afterOlderSaves: true });
|
||||
const panel = userEdit(() => internal())();
|
||||
const drag = beginStudioPendingEdit(null);
|
||||
const dragged = drag.adopt(() => internal());
|
||||
fetched();
|
||||
await Promise.all([panel, dragged]);
|
||||
drag.settle();
|
||||
await userEdit(async () => undefined)();
|
||||
remove();
|
||||
expect(isStudioEditSaving()).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a second burst flushed behind the first one save, though its commit is a panel action", async () => {
|
||||
const order: string[] = [];
|
||||
let fetched!: () => void;
|
||||
const commitNudge = trackedStudioEdit(
|
||||
async (name: string) => {
|
||||
if (name === "nudge A") await new Promise<void>((resolve) => (fetched = resolve));
|
||||
else await Promise.resolve();
|
||||
order.push(name);
|
||||
},
|
||||
{ afterOlderSaves: true },
|
||||
);
|
||||
let burst: { name: string; edit: ReturnType<typeof beginStudioPendingEdit> } | null = null;
|
||||
const startBurst = (name: string) => {
|
||||
const edit = beginStudioPendingEdit(null);
|
||||
burst = { name, edit };
|
||||
};
|
||||
const remove = addStudioPendingEditFlushListener(() => {
|
||||
if (!burst) return undefined;
|
||||
const { name, edit } = burst;
|
||||
burst = null;
|
||||
const saved = edit.adopt(() => commitNudge(name));
|
||||
edit.settle(saved);
|
||||
return saved;
|
||||
});
|
||||
const panelEdit = trackedStudioEdit(async (name: string) => void order.push(name), {
|
||||
afterOlderSaves: true,
|
||||
});
|
||||
startBurst("nudge A");
|
||||
const first = panelEdit("W 200");
|
||||
startBurst("nudge B");
|
||||
const second = panelEdit("W 300");
|
||||
fetched();
|
||||
await Promise.all([first, second]);
|
||||
remove();
|
||||
expect(order).toEqual(["nudge A", "W 200", "nudge B", "W 300"]);
|
||||
expect(isStudioEditSaving()).toBe(false);
|
||||
});
|
||||
|
||||
it("never flushes from inside an edit's own save, where a flushed save would go untracked", () => {
|
||||
let flushes = 0;
|
||||
const remove = addStudioPendingEditFlushListener(() => void (flushes += 1));
|
||||
const drag = beginStudioPendingEdit(null);
|
||||
drag.adopt(() => trackedStudioEdit(() => undefined)());
|
||||
drag.settle();
|
||||
remove();
|
||||
expect(flushes).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trackedStudioEdit", () => {
|
||||
it("counts each call as a pending edit until it settles, and reports a failure to the drain", async () => {
|
||||
let fail!: () => void;
|
||||
|
||||
@@ -117,10 +117,34 @@ export function trackStudioPendingEdit(
|
||||
return promise;
|
||||
}
|
||||
|
||||
let flushedSavesStillWriting: Promise<unknown> | null = null;
|
||||
|
||||
function commitOlderDebouncedEdits(): void {
|
||||
if (typeof window === "undefined" || adopting) return;
|
||||
const detail: StudioFlushPendingEditsDetail = { promises: [] };
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
|
||||
);
|
||||
if (!detail.promises.length) return;
|
||||
const saves = Promise.allSettled([flushedSavesStillWriting, ...detail.promises]);
|
||||
flushedSavesStillWriting = saves;
|
||||
void saves.then(() => {
|
||||
if (flushedSavesStillWriting === saves) flushedSavesStillWriting = null;
|
||||
});
|
||||
}
|
||||
|
||||
function afterOlderSaves<T>(run: () => T): Promise<T> | null {
|
||||
return flushedSavesStillWriting && flushedSavesStillWriting.then(run);
|
||||
}
|
||||
|
||||
export function trackedStudioEdit<Args extends unknown[], R>(
|
||||
edit: (...args: Args) => R,
|
||||
{ afterOlderSaves: waits = false } = {},
|
||||
): (...args: Args) => R {
|
||||
return (...args) => {
|
||||
commitOlderDebouncedEdits();
|
||||
const deferred = waits && !adopting ? afterOlderSaves(() => edit(...args)) : null;
|
||||
if (deferred) return trackStudioPendingEdit(deferred) as R;
|
||||
const result = edit(...args);
|
||||
if (result instanceof Promise) trackStudioPendingEdit(result);
|
||||
return result;
|
||||
@@ -128,6 +152,7 @@ export function trackedStudioEdit<Args extends unknown[], R>(
|
||||
}
|
||||
|
||||
export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
|
||||
commitOlderDebouncedEdits();
|
||||
let settle!: (saved?: Promise<unknown>) => void;
|
||||
const promise = trackStudioPendingEdit(new Promise<unknown>((resolve) => (settle = resolve)))!;
|
||||
const entry = pendingEdits.get(promise)!;
|
||||
@@ -167,7 +192,8 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
|
||||
// Only what `start` registers synchronously is adopted; a later write joins only through `within`.
|
||||
adopt<T>(start: () => T): T {
|
||||
try {
|
||||
const committed = inFlight.within(start);
|
||||
const deferred = afterOlderSaves(() => inFlight.within(start));
|
||||
const committed = (deferred ?? inFlight.within(start)) as T;
|
||||
landed = Promise.resolve(committed).then(
|
||||
() => true,
|
||||
() => saved,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@hyperframes/player": ["../player/src/hyperframes-player.ts"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user