Skip to content

Commit 9a56452

Browse files
committed
fix(studio): undo paints a move or nudge back at once while its save is still running
1 parent a3e9889 commit 9a56452

8 files changed

Lines changed: 277 additions & 29 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// @vitest-environment happy-dom
2+
import { afterEach, expect, it, vi } from "vitest";
3+
import type { DomEditSelection } from "./domEditing";
4+
import type { GestureState } from "./domEditOverlayGestures";
5+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6+
import {
7+
hasStudioPendingEdits,
8+
revertNewestStudioPendingEdit,
9+
} from "../../utils/studioPendingEdits";
10+
11+
afterEach(() => {
12+
document.body.innerHTML = "";
13+
});
14+
15+
const ref = <T>(current: T) => ({ current });
16+
const pointer = (x: number, y: number) => ({
17+
clientX: x,
18+
clientY: y,
19+
pointerId: 1,
20+
button: 0,
21+
preventDefault() {},
22+
stopPropagation() {},
23+
currentTarget: { setPointerCapture() {}, releasePointerCapture() {} },
24+
});
25+
26+
/** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
27+
function dragWithSaveRunning(save: Promise<void>) {
28+
const element = document.createElement("div");
29+
element.style.setProperty("translate", "40px 30px");
30+
document.body.append(element);
31+
const selection = { element, capabilities: { canApplyManualOffset: true } };
32+
const handlers = createDomEditOverlayGestureHandlers({
33+
selectionRef: ref(selection as unknown as DomEditSelection),
34+
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
35+
boxRef: ref(document.createElement("div")),
36+
overlayRef: ref(null),
37+
iframeRef: ref(null),
38+
gestureRef: ref<GestureState | null>(null),
39+
rafPausedRef: ref(false),
40+
onManualDragStartRef: ref(vi.fn()),
41+
onBlockedMoveRef: ref(vi.fn()),
42+
onPathOffsetCommitRef: ref(vi.fn(() => save)),
43+
snapGuidesRef: ref(null),
44+
groupGestureRef: ref(null),
45+
blockedMoveRef: ref(null),
46+
setOverlayRect: vi.fn(),
47+
suppressNextBoxClickRef: ref(false),
48+
hoverSelectionRef: ref(null),
49+
onCanvasMouseDown: vi.fn(),
50+
} as never);
51+
expect(handlers.startGesture("drag", pointer(10, 10) as never)).toBe(true);
52+
handlers.onPointerUp(pointer(110, 70) as never);
53+
return element;
54+
}
55+
56+
it("a drag whose save is still running can be painted back at once, and shown again", async () => {
57+
let saved!: () => void;
58+
const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
59+
const moved = element.style.getPropertyValue("translate");
60+
expect(moved).not.toBe("40px 30px");
61+
62+
const showAgain = revertNewestStudioPendingEdit();
63+
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
64+
expect(revertNewestStudioPendingEdit()).toBeNull();
65+
showAgain!();
66+
expect(element.style.getPropertyValue("translate")).toBe(moved);
67+
68+
saved();
69+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
70+
expect(revertNewestStudioPendingEdit()).toBeNull();
71+
});

‎packages/studio/src/components/editor/manualOffsetDrag.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
1414
import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
1515
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16+
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
1617

1718
interface OffsetDragGsap {
1819
set: (el: Element, vars: Record<string, number | string>) => void;
@@ -507,6 +508,16 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
507508
endStudioManualEditGesture(member.element, member.gestureToken);
508509
}
509510

511+
/** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
512+
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
513+
if (!members.every((member) => member.plainTranslate)) return null;
514+
return () => {
515+
const shown = members.map((member) => captureStudioPathOffset(member.element));
516+
for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
517+
return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
518+
};
519+
}
520+
510521
/** Roll back a FAILED drag to the exact gesture-start state. */
511522
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
512523
for (const member of members) {

‎packages/studio/src/components/editor/useDomEditNudge.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { ownsPlainKeys } from "../../utils/typingTarget";
1111
import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
1212
import {
1313
addStudioPendingEditFlushListener,
14-
trackStudioPendingEdit,
14+
beginStudioPendingEdit,
1515
} from "../../utils/studioPendingEdits";
1616
import type { DomEditSelection } from "./domEditing";
1717
import {
@@ -31,6 +31,7 @@ import {
3131
applyManualOffsetNudgeDraft,
3232
createManualOffsetDragMember,
3333
endManualOffsetDragMembers,
34+
manualOffsetMoveRevert,
3435
restoreManualOffsetDragMembers,
3536
type ManualOffsetDragMember,
3637
} from "./manualOffsetDrag";
@@ -47,7 +48,7 @@ interface NudgeSession {
4748
/** Accumulated delta of the burst, in composition px. */
4849
accum: { x: number; y: number };
4950
timer: ReturnType<typeof setTimeout> | null;
50-
endPendingEdit: (saved?: Promise<unknown>) => void;
51+
edit: ReturnType<typeof beginStudioPendingEdit>;
5152
}
5253

5354
export interface UseDomEditNudgeParams {
@@ -161,11 +162,16 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
161162
next: applyManualOffsetNudgeCommit(member, session.accum),
162163
plainTranslate: member.plainTranslate,
163164
}));
164-
const commit = session.isGroup
165-
? p.onGroupPathOffsetCommitRef.current(updates)
166-
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
167-
plainTranslate: updates[0].plainTranslate,
168-
});
165+
const commit = session.edit.adopt(() =>
166+
session.isGroup
167+
? p.onGroupPathOffsetCommitRef.current(updates)
168+
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
169+
plainTranslate: updates[0].plainTranslate,
170+
}),
171+
);
172+
if (session.edit.reverted())
173+
for (const member of session.members)
174+
restoreStudioPathOffset(member.element, member.initialPathOffset);
169175
saved = Promise.resolve(commit)
170176
.catch(() => {
171177
for (const member of session.members) {
@@ -177,7 +183,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
177183
.finally(() => endManualOffsetDragMembers(session.members));
178184
return saved;
179185
} finally {
180-
session.endPendingEdit(saved);
186+
session.edit.settle(saved);
181187
}
182188
};
183189
const commitSessionRef = useRef(commitSession);
@@ -190,7 +196,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
190196
if (session.timer) clearTimeout(session.timer);
191197
restoreManualOffsetDragMembers(session.members);
192198
endManualOffsetDragMembers(session.members);
193-
session.endPendingEdit();
199+
session.edit.settle();
194200
}, [params.allowCanvasMovement]);
195201

196202
// Build drag members for the current target set — the same member snapshot a
@@ -217,9 +223,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
217223
if (members.length === 0) return null;
218224
// Same side effect a drag start has (pauses preview playback).
219225
p.onManualDragStartRef.current?.();
220-
let endPendingEdit: NudgeSession["endPendingEdit"] = () => {};
221-
trackStudioPendingEdit(new Promise<unknown>((resolve) => (endPendingEdit = resolve)));
222-
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit };
226+
const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members));
227+
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit };
223228
};
224229

225230
const handleKeyDown = (event: KeyboardEvent) => {

‎packages/studio/src/components/editor/useDomEditOverlayGestures.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
applyManualOffsetDragDraft,
1212
endManualOffsetDragMembers,
1313
restoreManualOffsetDragMembers,
14+
manualOffsetMoveRevert,
1415
} from "./manualOffsetDrag";
1516
import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
1617
import {
@@ -57,6 +58,7 @@ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDeb
5758
import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
5859
import { createGroupDragMover } from "./groupDragMove";
5960
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
61+
import { beginStudioPendingEdit } from "../../utils/studioPendingEdits";
6062

6163
function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) {
6264
return (
@@ -344,7 +346,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
344346
),
345347
at: readDragPositions(groupG.members),
346348
});
347-
void Promise.resolve(opts.onGroupPathOffsetCommitRef.current(updates))
349+
const groupEdit = beginStudioPendingEdit(manualOffsetMoveRevert(groupG.members));
350+
const groupSaved = Promise.resolve(
351+
groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
352+
)
348353
.catch(() => {
349354
for (const member of groupG.members) {
350355
if (
@@ -363,6 +368,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
363368
// so this is where a snap-back would show.
364369
logDragSettle("settle", groupG.members);
365370
});
371+
groupEdit.settle(groupSaved);
366372
return;
367373
}
368374

@@ -469,11 +475,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
469475
box.style.left = `${nextBoxLeft}px`;
470476
box.style.top = `${nextBoxTop}px`;
471477
}
472-
void Promise.resolve(
473-
opts.onPathOffsetCommitRef.current(sel, finalOffset, {
474-
altKey: e.altKey,
475-
plainTranslate: g.pathOffsetMember.plainTranslate,
476-
}),
478+
const member = g.pathOffsetMember;
479+
const edit = beginStudioPendingEdit(manualOffsetMoveRevert([member]));
480+
const saved = Promise.resolve(
481+
edit.adopt(() =>
482+
opts.onPathOffsetCommitRef.current(sel, finalOffset, {
483+
altKey: e.altKey,
484+
plainTranslate: member.plainTranslate,
485+
}),
486+
),
477487
)
478488
.catch(() => {
479489
if (
@@ -485,6 +495,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
485495
.finally(() => {
486496
if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
487497
});
498+
edit.settle(saved);
488499
} else {
489500
opts.suppressNextBoxClickRef.current = true;
490501
const finalSize = readStudioBoxSize(sel.element);

‎packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,9 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
206206
const s = await studio();
207207
await s.edit();
208208
resetNudgeKeys();
209+
let finish!: () => void;
209210
const save = vi.fn(async () => {
211+
await new Promise<void>((resolve) => (finish = resolve));
210212
writeFileSync(s.path, NUDGED);
211213
await s.history().recordEdit({
212214
label: "Move layer",
@@ -218,12 +220,51 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
218220
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
219221
});
220222

223+
const nudge = () => s.element("box").style.getPropertyValue("translate");
224+
expect(nudge()).not.toBe("");
225+
221226
const undone = s.actions().undo();
222227
expect(s.box()).toBe("50px");
228+
expect(nudge()).toBe("");
229+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
230+
expect(nudge()).toBe("");
231+
finish();
223232
await act(() => undone);
224233
expect(save).toHaveBeenCalledTimes(1);
225234
expect(s.file()).toBe(AFTER);
226235
expect(s.box()).toBe("50px");
236+
expect(nudge()).toBe("");
237+
});
238+
239+
it("an undo pressed while a nudge's save is queued shows the nudge undone in the key's own task", async () => {
240+
const s = await studio();
241+
await s.edit();
242+
resetNudgeKeys();
243+
let finish!: () => void;
244+
const save = vi.fn(async () => {
245+
await new Promise<void>((resolve) => (finish = resolve));
246+
writeFileSync(s.path, NUDGED);
247+
await s.history().recordEdit({
248+
label: "Move layer",
249+
files: { "index.html": { before: AFTER, after: NUDGED } },
250+
});
251+
});
252+
s.mount(createElement(Nudge, { target: s.element("box"), save }));
253+
act(() => {
254+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
255+
});
256+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
257+
const nudge = () => s.element("box").style.getPropertyValue("translate");
258+
expect(nudge()).not.toBe("");
259+
260+
const undone = s.actions().undo();
261+
expect(nudge()).toBe("");
262+
expect(s.box()).toBe("50px");
263+
finish();
264+
await act(() => undone);
265+
expect(s.file()).toBe(AFTER);
266+
expect(nudge()).toBe("");
267+
expect(s.box()).toBe("50px");
227268
});
228269

229270
it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {

‎packages/studio/src/hooks/useEditHistoryActions.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react";
33
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
44
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
55
import type { RestoreFiles } from "../utils/gsapUndoRestore";
6+
import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits";
67

78
interface HistoryResult {
89
ok: boolean;
@@ -74,7 +75,10 @@ export function useEditHistoryActions({
7475
const noun = direction === "undo" ? "Undo" : "Redo";
7576
// Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
7677
const predicted = editHistory.predict?.(direction) ?? null;
77-
const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
78+
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
79+
const pendingEditShown =
80+
!predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null;
81+
const putBack = predictedShown ?? pendingEditShown;
7882
let result: HistoryResult = { ok: false, reason: "failed" };
7983
let serverSteppedShown = false;
8084
try {
@@ -83,8 +87,10 @@ export function useEditHistoryActions({
8387
readFile: readHistoryFile,
8488
serialize: serializeHistoryFiles,
8589
});
86-
serverSteppedShown =
87-
Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id;
90+
const stepped = Boolean(result.ok && result.label);
91+
serverSteppedShown = predictedShown
92+
? stepped && result.undoes === predicted?.id
93+
: stepped && Boolean(pendingEditShown);
8894
} finally {
8995
if (putBack && !serverSteppedShown) putBack();
9096
}
@@ -100,7 +106,10 @@ export function useEditHistoryActions({
100106
return;
101107
}
102108
if (result.ok && result.label) {
103-
const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files;
109+
const files =
110+
serverSteppedShown && predictedShown
111+
? fromShown(result.files, predicted!.files)
112+
: result.files;
104113
const restore = { paths: result.paths, files };
105114
onAfterUndoRedo?.(restore);
106115
if (activeCompPath && result.paths?.includes(activeCompPath)) {

0 commit comments

Comments
 (0)