From cb36d5fb40910e65a11736cf69caedee4218a6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 17:05:05 -0400 Subject: [PATCH 1/2] fix(studio): undo keeps a reloaded GSAP edit undone and spares an element a newer gesture took --- .../components/editor/dragUndoPaint.test.ts | 52 +++++++++++++++++-- .../components/editor/gestureUndoRevert.ts | 32 +++++++++--- .../src/components/editor/manualEdits.ts | 1 + .../src/components/editor/manualEditsDom.ts | 5 +- .../src/hooks/useGsapScriptCommits.test.tsx | 29 ++++++++++- .../studio/src/hooks/useGsapScriptCommits.ts | 26 +++++++--- .../src/utils/studioPendingEdits.test.ts | 32 ++++++++++++ .../studio/src/utils/studioPendingEdits.ts | 25 +++++++-- 8 files changed, 180 insertions(+), 22 deletions(-) diff --git a/packages/studio/src/components/editor/dragUndoPaint.test.ts b/packages/studio/src/components/editor/dragUndoPaint.test.ts index 4a36fd34ee..1b47488e27 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 } 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,40 @@ 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("leaves an element alone once a newer gesture took it, though its painted-back resize still saves", async () => { + let edit!: StudioEditInFlight; + let release!: () => void; + const element = gestureWithSaveRunning("resize", () => { + edit = adoptingStudioPendingEdit()!; + return new Promise((resolve) => (release = resolve)); + }); + const shown = paintBackNewestStudioPendingEdit(); + beginStudioManualEditGesture(element, "resize"); + element.style.setProperty("width", "300px"); + const newer = element.getAttribute("style"); + + edit.drawKeepingUndone(() => undefined); + expect(element.getAttribute("style")).toBe(newer); + shown!.showAgain(); + expect(element.getAttribute("style")).toBe(newer); + + release(); + await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false)); +}); + +it("leaves an element alone once a newer gesture took it, though its painted-back drag still saves", async () => { + let saved!: () => void; + const element = dragWithSaveRunning(new Promise((resolve) => (saved = resolve))); + const shown = paintBackNewestStudioPendingEdit(); + beginStudioManualEditGesture(element, "move"); + element.style.setProperty("translate", "5px 5px"); + + shown!.showAgain(); + expect(element.style.getPropertyValue("translate")).toBe("5px 5px"); + expect(paintBackNewestStudioPendingEdit()).not.toBeNull(); + expect(element.style.getPropertyValue("translate")).toBe("5px 5px"); + + saved(); + await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false)); +}); diff --git a/packages/studio/src/components/editor/gestureUndoRevert.ts b/packages/studio/src/components/editor/gestureUndoRevert.ts index 30bde86bfb..b213802345 100644 --- a/packages/studio/src/components/editor/gestureUndoRevert.ts +++ b/packages/studio/src/components/editor/gestureUndoRevert.ts @@ -1,5 +1,6 @@ import { captureStudioPathOffset, + latestStudioGestureOn, restoreStudioPathOffset, type StudioPathOffsetSnapshot, } from "./manualEdits"; @@ -16,6 +17,12 @@ interface MemberPosition { placement: string[]; } +/** True until a newer gesture begins on the element. */ +function noGestureSince(element: HTMLElement): () => boolean { + const latest = latestStudioGestureOn(element); + return () => latestStudioGestureOn(element) === latest; +} + // Where an element-offset move draws the element when no transform channel can take it. const PLACEMENT = ["position", "left", "top"]; const placementOf = (element: HTMLElement) => @@ -51,17 +58,26 @@ function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosi /** Undo's live revert of a move: its members at gesture start. */ export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert { - const startPlacement = members.map((member) => placementOf(member.element)); + const start = new Map( + members.map((member) => [ + member, + { placement: placementOf(member.element), untouched: noGestureSince(member.element) }, + ]), + ); return () => { - const shown = members.map(readMemberPosition); - members.forEach((member, i) => + const reverted = members.filter((member) => start.get(member)!.untouched()); + const shown = reverted.map(readMemberPosition); + reverted.forEach((member) => showMemberPosition(member, { offset: member.initialPathOffset, gsap: member.plainTranslate ? null : member.baseGsap, - placement: startPlacement[i]!, + placement: start.get(member)!.placement, }), ); - return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!)); + return () => + reverted.forEach((member, i) => { + if (start.get(member)!.untouched()) showMemberPosition(member, shown[i]!); + }); }; } @@ -96,9 +112,13 @@ export function elementLookRevert( element: HTMLElement, start: StudioElementLook, ): StudioEditRevert { + const untouched = noGestureSince(element); return () => { + if (!untouched()) return () => {}; const shown = readElementLook(element, start.gsap !== null); showElementLook(element, start); - return () => showElementLook(element, shown); + return () => { + if (untouched()) showElementLook(element, shown); + }; }; } diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 190027ad84..081c9c7ceb 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -21,6 +21,7 @@ export { beginStudioManualEditGesture, endStudioManualEditGesture, isStudioManualEditGestureCurrent, + latestStudioGestureOn, readStudioPathOffset, readAppliedStudioPathOffset, readStudioBoxSize, diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 9a3ea22adf..6b579e81fd 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -39,6 +39,7 @@ import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; +const latestGestureOn = new WeakMap(); export type StudioGestureDraws = "move" | "resize" | "rotate" | "edit"; const MOVE_DRAWS = ["translate", STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP]; @@ -53,12 +54,14 @@ export function beginStudioManualEditGesture( element: HTMLElement, draws: StudioGestureDraws, ): string { - studioManualEditGestureId += 1; + latestGestureOn.set(element, ++studioManualEditGestureId); const token = `gesture-${studioManualEditGestureId}:${draws}`; element.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, token); return token; } +export const latestStudioGestureOn = (element: HTMLElement) => latestGestureOn.get(element); + const GESTURE_ENDED = "hf-manual-edit-gesture-ended"; export function endStudioManualEditGesture(element: HTMLElement, token?: string): void { diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index 7718051724..b28d2bb272 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 3082f97a2e..dc626af9b4 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 306fe2d0fc..588567d60b 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -364,3 +364,35 @@ 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(); +}); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index e0842ec643..81270c46d6 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,9 +131,13 @@ 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(); @@ -168,9 +179,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, From 031f4ffb402c62a0b0e611e288ac3b8b7c61ac08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 17:35:05 -0400 Subject: [PATCH 2/2] fix(studio): a painted-back edit's late writes keep what a newer gesture drew --- .../components/editor/dragUndoPaint.test.ts | 38 +++++++++++++------ .../components/editor/gestureUndoRevert.ts | 32 +++------------- .../src/components/editor/manualEdits.ts | 1 - .../src/components/editor/manualEditsDom.ts | 5 +-- .../src/utils/studioPendingEdits.test.ts | 19 ++++++++++ .../studio/src/utils/studioPendingEdits.ts | 3 ++ 6 files changed, 55 insertions(+), 43 deletions(-) diff --git a/packages/studio/src/components/editor/dragUndoPaint.test.ts b/packages/studio/src/components/editor/dragUndoPaint.test.ts index 1b47488e27..ba5dd6a880 100644 --- a/packages/studio/src/components/editor/dragUndoPaint.test.ts +++ b/packages/studio/src/components/editor/dragUndoPaint.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, expect, it, vi } from "vitest"; import { savePlainRotation } from "../../hooks/plainRotation"; -import { beginStudioManualEditGesture } from "./manualEdits"; +import { beginStudioManualEditGesture, endStudioManualEditGesture } from "./manualEdits"; import type { DomEditSelection } from "./domEditing"; import type { GestureState } from "./domEditOverlayGestures"; import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; @@ -247,7 +247,7 @@ it("keeps a GSAP drag's base through every repaint of its undo, so its save adds await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false)); }); -it("leaves an element alone once a newer gesture took it, though its painted-back resize still saves", async () => { +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", () => { @@ -255,30 +255,44 @@ it("leaves an element alone once a newer gesture took it, though its painted-bac return new Promise((resolve) => (release = resolve)); }); const shown = paintBackNewestStudioPendingEdit(); - beginStudioManualEditGesture(element, "resize"); element.style.setProperty("width", "300px"); const newer = element.getAttribute("style"); - edit.drawKeepingUndone(() => undefined); + edit.drawKeepingUndone(() => element.style.setProperty("width", "999px")); expect(element.getAttribute("style")).toBe(newer); shown!.showAgain(); - expect(element.getAttribute("style")).toBe(newer); + 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("leaves an element alone once a newer gesture took it, though its painted-back drag still saves", async () => { +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(); - beginStudioManualEditGesture(element, "move"); - element.style.setProperty("translate", "5px 5px"); - + endStudioManualEditGesture(element, beginStudioManualEditGesture(element, "move")); shown!.showAgain(); - expect(element.style.getPropertyValue("translate")).toBe("5px 5px"); - expect(paintBackNewestStudioPendingEdit()).not.toBeNull(); - expect(element.style.getPropertyValue("translate")).toBe("5px 5px"); + expect(element.style.getPropertyValue("translate")).toBe(moved); saved(); await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false)); diff --git a/packages/studio/src/components/editor/gestureUndoRevert.ts b/packages/studio/src/components/editor/gestureUndoRevert.ts index b213802345..30bde86bfb 100644 --- a/packages/studio/src/components/editor/gestureUndoRevert.ts +++ b/packages/studio/src/components/editor/gestureUndoRevert.ts @@ -1,6 +1,5 @@ import { captureStudioPathOffset, - latestStudioGestureOn, restoreStudioPathOffset, type StudioPathOffsetSnapshot, } from "./manualEdits"; @@ -17,12 +16,6 @@ interface MemberPosition { placement: string[]; } -/** True until a newer gesture begins on the element. */ -function noGestureSince(element: HTMLElement): () => boolean { - const latest = latestStudioGestureOn(element); - return () => latestStudioGestureOn(element) === latest; -} - // Where an element-offset move draws the element when no transform channel can take it. const PLACEMENT = ["position", "left", "top"]; const placementOf = (element: HTMLElement) => @@ -58,26 +51,17 @@ function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosi /** Undo's live revert of a move: its members at gesture start. */ export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert { - const start = new Map( - members.map((member) => [ - member, - { placement: placementOf(member.element), untouched: noGestureSince(member.element) }, - ]), - ); + const startPlacement = members.map((member) => placementOf(member.element)); return () => { - const reverted = members.filter((member) => start.get(member)!.untouched()); - const shown = reverted.map(readMemberPosition); - reverted.forEach((member) => + const shown = members.map(readMemberPosition); + members.forEach((member, i) => showMemberPosition(member, { offset: member.initialPathOffset, gsap: member.plainTranslate ? null : member.baseGsap, - placement: start.get(member)!.placement, + placement: startPlacement[i]!, }), ); - return () => - reverted.forEach((member, i) => { - if (start.get(member)!.untouched()) showMemberPosition(member, shown[i]!); - }); + return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!)); }; } @@ -112,13 +96,9 @@ export function elementLookRevert( element: HTMLElement, start: StudioElementLook, ): StudioEditRevert { - const untouched = noGestureSince(element); return () => { - if (!untouched()) return () => {}; const shown = readElementLook(element, start.gsap !== null); showElementLook(element, start); - return () => { - if (untouched()) showElementLook(element, shown); - }; + return () => showElementLook(element, shown); }; } diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 081c9c7ceb..190027ad84 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -21,7 +21,6 @@ export { beginStudioManualEditGesture, endStudioManualEditGesture, isStudioManualEditGestureCurrent, - latestStudioGestureOn, readStudioPathOffset, readAppliedStudioPathOffset, readStudioBoxSize, diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 6b579e81fd..9a3ea22adf 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -39,7 +39,6 @@ import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; -const latestGestureOn = new WeakMap(); export type StudioGestureDraws = "move" | "resize" | "rotate" | "edit"; const MOVE_DRAWS = ["translate", STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP]; @@ -54,14 +53,12 @@ export function beginStudioManualEditGesture( element: HTMLElement, draws: StudioGestureDraws, ): string { - latestGestureOn.set(element, ++studioManualEditGestureId); + studioManualEditGestureId += 1; const token = `gesture-${studioManualEditGestureId}:${draws}`; element.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, token); return token; } -export const latestStudioGestureOn = (element: HTMLElement) => latestGestureOn.get(element); - const GESTURE_ENDED = "hf-manual-edit-gesture-ended"; export function endStudioManualEditGesture(element: HTMLElement, token?: string): void { diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 588567d60b..39da36e321 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -396,3 +396,22 @@ it("is no longer painted back once shown again, so a later draw shows at once", 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 81270c46d6..f9cef28707 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -141,11 +141,14 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) { }, 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),