Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 63 additions & 3 deletions packages/studio/src/components/editor/dragUndoPaint.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from "vitest";
import { savePlainRotation } from "../../hooks/plainRotation";
import { beginStudioManualEditGesture, endStudioManualEditGesture } from "./manualEdits";
import type { DomEditSelection } from "./domEditing";
import type { GestureState } from "./domEditOverlayGestures";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
Expand Down Expand Up @@ -96,7 +97,11 @@ it("a drag whose save is still running can be painted back at once, and shown ag
});

/** Resizes or rotates a plain 240x160 box; the commit draws what it saves at once and its save waits for `save`. */
function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise<void>) {
function gestureWithSaveRunning(
kind: "resize" | "rotate",
save: Promise<void> | (() => Promise<void>),
) {
const saving = typeof save === "function" ? save : () => save;
const element = document.createElement("div");
element.setAttribute(
"style",
Expand All @@ -118,11 +123,15 @@ function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise<void>)
onBoxSizeCommitRef: ref(
vi.fn(() => {
element.style.setProperty("clip-path", "inset(15px)");
return save;
return saving();
}),
),
onRotationCommitRef: ref((sel: DomEditSelection, next: never) =>
savePlainRotation({ commitPositionPatchToHtml: () => save.then(() => undefined) }, sel, next),
savePlainRotation(
{ commitPositionPatchToHtml: () => saving().then(() => undefined) },
sel,
next,
),
),
snapGuidesRef: ref(null),
groupGestureRef: ref(null),
Expand Down Expand Up @@ -237,3 +246,54 @@ it("keeps a GSAP drag's base through every repaint of its undo, so its save adds
release();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});

it("a late write of a painted-back resize leaves what a newer gesture drew", async () => {
let edit!: StudioEditInFlight;
let release!: () => void;
const element = gestureWithSaveRunning("resize", () => {
edit = adoptingStudioPendingEdit()!;
return new Promise<void>((resolve) => (release = resolve));
});
const shown = paintBackNewestStudioPendingEdit();
element.style.setProperty("width", "300px");
const newer = element.getAttribute("style");

edit.drawKeepingUndone(() => element.style.setProperty("width", "999px"));
expect(element.getAttribute("style")).toBe(newer);
shown!.showAgain();
expect(element.style.getPropertyValue("width")).toBe("999px");

release();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});

it("a late write of a painted-back GSAP drag leaves where a newer drag holds the box", async () => {
const gsapOf = fakeGsap({ x: 5, y: 7 });
let edit!: StudioEditInFlight;
let release!: () => void;
const element = dragWithSaveRunning(() => {
edit = adoptingStudioPendingEdit()!;
return new Promise<void>((resolve) => (release = resolve));
});
paintBackNewestStudioPendingEdit();
gsapOf(element).x = 40;

edit.drawKeepingUndone(() => void (gsapOf(element).x = 300));
expect(gsapOf(element).x).toBe(40);

release();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});

it("shows a refused undo's drag again though the box was tapped meanwhile", async () => {
let saved!: () => void;
const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
const moved = element.style.getPropertyValue("translate");
const shown = paintBackNewestStudioPendingEdit();
endStudioManualEditGesture(element, beginStudioManualEditGesture(element, "move"));
shown!.showAgain();
expect(element.style.getPropertyValue("translate")).toBe(moved);

saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});
29 changes: 28 additions & 1 deletion packages/studio/src/hooks/useGsapScriptCommits.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ vi.mock("./gsapRuntimePatch", () => ({
vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
applySoftReload: (...args: unknown[]) => applySoftReload(...args),
extractGsapScriptText: () => "",
extractGsapScriptText: (html: string) => (html === "BEFORE" ? "OLD SCRIPT" : ""),
readNestedFiles: (...args: unknown[]) => readNestedFiles(...args),
}));
vi.mock("../utils/studioTelemetry", () => ({
Expand Down Expand Up @@ -531,6 +531,33 @@ describe("a GSAP script commit", () => {
shown!.showAgain();
expect(applySoftReload).toHaveBeenCalledTimes(1);
});

it("reloads a GSAP edit whose preview already reloaded back to its old file when undo paints it back", async () => {
applySoftReload.mockReset();
applySoftReload.mockReturnValue("applied");
mockFetchResult();
const deps = renderCommitHook();
const edit = beginStudioPendingEdit(() => () => undefined);
const committed = edit.adopt(() =>
observeGsapGesture(deps.api.commitMutation).commit!(
selection,
{ x: 10 },
{ label: "Move layer", softReload: true },
),
);
let release!: () => void;
edit.settle(Promise.all([committed, new Promise<void>((resolve) => (release = resolve))]));
await act(async () => void (await committed));
expect(applySoftReload).toHaveBeenCalledTimes(1);

const shown = paintBackNewestStudioPendingEdit()!;
expect(applySoftReload).toHaveBeenCalledTimes(2);
expect(applySoftReload.mock.calls[1]![2]).toMatchObject({ authoredHtml: "BEFORE" });
shown.showAgain();
expect(applySoftReload).toHaveBeenCalledTimes(3);
expect(applySoftReload.mock.calls[2]![2]).toMatchObject({ authoredHtml: "AFTER" });
release();
});
});

describe("runCommit — instantPatch wiring", () => {
Expand Down
26 changes: 20 additions & 6 deletions packages/studio/src/hooks/useGsapScriptCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,12 @@ function finishUnchangedMutation(
return true;
}

function redrawUnlessPaintedBack(options: CommitMutationOptions, redraw: () => void): void {
if (options.pendingEdit) options.pendingEdit.drawUnlessUndone(redraw);
function redrawUnlessPaintedBack(
options: CommitMutationOptions,
redraw: () => void,
undraw?: () => void,
): void {
if (options.pendingEdit) options.pendingEdit.drawUnlessUndone(redraw, undraw);
else redraw();
}

Expand All @@ -133,10 +137,20 @@ function refreshMutationPreview(
onCacheInvalidate: () => void,
nestedFiles?: Map<string, string> | null,
): void {
redrawUnlessPaintedBack(options, () => {
options.beforeReload?.();
applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
});
const before = result.before;
const oldScript = before ? extractGsapScriptText(before) : null;
// ponytail: no escalation; a full reload would load the edited file back from disk.
const undraw = oldScript
? () => softReloadOrEscalate(iframe, oldScript, () => {}, "preview_sync", before!, nestedFiles)
: undefined;
redrawUnlessPaintedBack(
options,
() => {
options.beforeReload?.();
applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
},
undraw,
);
onCacheInvalidate();
}

Expand Down
51 changes: 51 additions & 0 deletions packages/studio/src/utils/studioPendingEdits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,54 @@ it("counts an edit whose later write failed as saved once an earlier write lande
const shown = paintBackNewestStudioPendingEdit()!;
await expect(shown.landed()).resolves.toBe(true);
});

it("undoes a redraw that already ran when the edit is painted back, and redoes it on show again", () => {
const edit = beginStudioPendingEdit(() => () => undefined);
const inFlight = edit.adopt(() => adoptingStudioPendingEdit())!;
let landSave!: () => void;
edit.settle(new Promise<void>((resolve) => (landSave = resolve)));
const drawn: string[] = [];
inFlight.drawUnlessUndone(
() => drawn.push("edit"),
() => drawn.push("undo edit"),
);
expect(drawn).toEqual(["edit"]);

const shown = paintBackNewestStudioPendingEdit()!;
expect(drawn).toEqual(["edit", "undo edit"]);
shown.showAgain();
expect(drawn).toEqual(["edit", "undo edit", "edit"]);
landSave();
});

it("is no longer painted back once shown again, so a later draw shows at once", () => {
const edit = beginStudioPendingEdit(() => () => undefined);
const inFlight = edit.adopt(() => adoptingStudioPendingEdit())!;
let landSave!: () => void;
edit.settle(new Promise<void>((resolve) => (landSave = resolve)));
paintBackNewestStudioPendingEdit()!.showAgain();
expect(inFlight.reverted()).toBe(false);
const draw = vi.fn();
inFlight.drawUnlessUndone(draw);
expect(draw).toHaveBeenCalledTimes(1);
landSave();
});

it("undoes an edit's drawn reloads newest first", () => {
const edit = beginStudioPendingEdit(() => () => undefined);
const inFlight = edit.adopt(() => adoptingStudioPendingEdit())!;
let landSave!: () => void;
edit.settle(new Promise<void>((resolve) => (landSave = resolve)));
const undone: string[] = [];
inFlight.drawUnlessUndone(
() => undefined,
() => undone.push("first"),
);
inFlight.drawUnlessUndone(
() => undefined,
() => undone.push("second"),
);
paintBackNewestStudioPendingEdit();
expect(undone).toEqual(["second", "first"]);
landSave();
});
28 changes: 23 additions & 5 deletions packages/studio/src/utils/studioPendingEdits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ interface PendingEdit {
revert: StudioEditRevert | null;
landed: () => Promise<boolean>;
redraws: Array<() => void>;
drawn: Array<{ draw: () => void; undraw: () => void }>;
showAgain: (() => void) | null;
}

export interface StudioEditInFlight {
reverted: () => boolean;
within: <T>(run: () => T) => T;
drawUnlessUndone: (draw: () => void) => void;
drawUnlessUndone: (draw: () => void, undraw?: () => void) => void;
drawKeepingUndone: <T>(draw: () => T) => T;
markSaved: () => void;
}
Expand Down Expand Up @@ -87,7 +88,13 @@ export function trackStudioPendingEdit(
if (!result) return undefined;
const promise = Promise.resolve(result);
if (adopting) return promise;
pendingEdits.set(promise, { revert: null, landed: NOT_SAVED, redraws: [], showAgain: null });
pendingEdits.set(promise, {
revert: null,
landed: NOT_SAVED,
redraws: [],
drawn: [],
showAgain: null,
});
promise.then(
() => pendingEdits.delete(promise),
() => pendingEdits.delete(promise),
Expand Down Expand Up @@ -124,17 +131,24 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
adopting = outer;
}
},
drawUnlessUndone(draw) {
if (inFlight.reverted()) entry.redraws.push(draw);
else draw();
drawUnlessUndone(draw, undraw) {
const drawNow = () => {
draw();
if (undraw) entry.drawn.push({ draw, undraw });
};
if (inFlight.reverted()) entry.redraws.push(drawNow);
else drawNow();
},
drawKeepingUndone(draw) {
if (!inFlight.reverted()) return draw();
// Ends on what the element showed before the draw, which a newer gesture may have drawn.
const backToNow = revert!();
entry.showAgain?.();
try {
return draw();
} finally {
entry.showAgain = revert!();
backToNow();
}
},
markSaved: () => void (saved = true),
Expand Down Expand Up @@ -168,9 +182,13 @@ export function paintBackNewestStudioPendingEdit(): {
if (!newest || !revert) return null;
newest.revert = null;
newest.showAgain = revert();
for (const { undraw } of [...newest.drawn].reverse()) undraw();
return {
showAgain: () => {
newest.revert = revert;
newest.showAgain?.();
newest.showAgain = null;
for (const { draw } of newest.drawn) draw();
for (const redraw of newest.redraws.splice(0)) redraw();
},
landed: newest.landed,
Expand Down
Loading