diff --git a/packages/studio/src/components/editor/dragUndoPaint.test.ts b/packages/studio/src/components/editor/dragUndoPaint.test.ts index 4a36fd34eec..ba5dd6a8804 100644 --- a/packages/studio/src/components/editor/dragUndoPaint.test.ts +++ b/packages/studio/src/components/editor/dragUndoPaint.test.ts @@ -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"; @@ -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) { +function gestureWithSaveRunning( + kind: "resize" | "rotate", + save: Promise | (() => Promise), +) { + const saving = typeof save === "function" ? save : () => save; const element = document.createElement("div"); element.setAttribute( "style", @@ -118,11 +123,15 @@ function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise) 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), @@ -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((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((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((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)); +}); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index 7718051724c..b28d2bb2720 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -18,7 +18,7 @@ vi.mock("./gsapRuntimePatch", () => ({ vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({ ...(await importOriginal()), applySoftReload: (...args: unknown[]) => applySoftReload(...args), - extractGsapScriptText: () => "", + extractGsapScriptText: (html: string) => (html === "BEFORE" ? "OLD SCRIPT" : ""), readNestedFiles: (...args: unknown[]) => readNestedFiles(...args), })); vi.mock("../utils/studioTelemetry", () => ({ @@ -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((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", () => { diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 3082f97a2e4..dc626af9b48 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -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(); } @@ -133,10 +137,20 @@ function refreshMutationPreview( onCacheInvalidate: () => void, nestedFiles?: Map | 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(); } diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 306fe2d0fcd..39da36e3215 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -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((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((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((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(); +}); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index e0842ec6430..f9cef287072 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -15,13 +15,14 @@ interface PendingEdit { revert: StudioEditRevert | null; landed: () => Promise; redraws: Array<() => void>; + drawn: Array<{ draw: () => void; undraw: () => void }>; showAgain: (() => void) | null; } export interface StudioEditInFlight { reverted: () => boolean; within: (run: () => T) => T; - drawUnlessUndone: (draw: () => void) => void; + drawUnlessUndone: (draw: () => void, undraw?: () => void) => void; drawKeepingUndone: (draw: () => T) => T; markSaved: () => void; } @@ -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), @@ -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), @@ -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,