Skip to content
Merged
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
71 changes: 71 additions & 0 deletions packages/studio/src/components/editor/dragUndoPaint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditing";
import type { GestureState } from "./domEditOverlayGestures";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import {
hasStudioPendingEdits,
revertNewestStudioPendingEdit,
} from "../../utils/studioPendingEdits";

afterEach(() => {
document.body.innerHTML = "";
});

const ref = <T>(current: T) => ({ current });
const pointer = (x: number, y: number) => ({
clientX: x,
clientY: y,
pointerId: 1,
button: 0,
preventDefault() {},
stopPropagation() {},
currentTarget: { setPointerCapture() {}, releasePointerCapture() {} },
});

/** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
function dragWithSaveRunning(save: Promise<void>) {
const element = document.createElement("div");
element.style.setProperty("translate", "40px 30px");
document.body.append(element);
const selection = { element, capabilities: { canApplyManualOffset: true } };
const handlers = createDomEditOverlayGestureHandlers({
selectionRef: ref(selection as unknown as DomEditSelection),
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
boxRef: ref(document.createElement("div")),
overlayRef: ref(null),
iframeRef: ref(null),
gestureRef: ref<GestureState | null>(null),
rafPausedRef: ref(false),
onManualDragStartRef: ref(vi.fn()),
onBlockedMoveRef: ref(vi.fn()),
onPathOffsetCommitRef: ref(vi.fn(() => save)),
snapGuidesRef: ref(null),
groupGestureRef: ref(null),
blockedMoveRef: ref(null),
setOverlayRect: vi.fn(),
suppressNextBoxClickRef: ref(false),
hoverSelectionRef: ref(null),
onCanvasMouseDown: vi.fn(),
} as never);
expect(handlers.startGesture("drag", pointer(10, 10) as never)).toBe(true);
handlers.onPointerUp(pointer(110, 70) as never);
return element;
}

it("a drag whose save is still running can be painted back at once, and shown again", async () => {
let saved!: () => void;
const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
const moved = element.style.getPropertyValue("translate");
expect(moved).not.toBe("40px 30px");

const showAgain = revertNewestStudioPendingEdit();
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
expect(revertNewestStudioPendingEdit()).toBeNull();
showAgain!();
expect(element.style.getPropertyValue("translate")).toBe(moved);

saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
expect(revertNewestStudioPendingEdit()).toBeNull();
});
11 changes: 11 additions & 0 deletions packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
import type { StudioEditRevert } from "../../utils/studioPendingEdits";

interface OffsetDragGsap {
set: (el: Element, vars: Record<string, number | string>) => void;
Expand Down Expand Up @@ -510,6 +511,16 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
endStudioManualEditGesture(member.element, member.gestureToken);
}

/** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
if (!members.every((member) => member.plainTranslate)) return null;
return () => {
const shown = members.map((member) => captureStudioPathOffset(member.element));
for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
};
}

/** Roll back a FAILED drag to the exact gesture-start state. */
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
Expand Down
29 changes: 17 additions & 12 deletions packages/studio/src/components/editor/useDomEditNudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ownsPlainKeys } from "../../utils/typingTarget";
import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
import {
addStudioPendingEditFlushListener,
trackStudioPendingEdit,
beginStudioPendingEdit,
} from "../../utils/studioPendingEdits";
import type { DomEditSelection } from "./domEditing";
import {
Expand All @@ -31,6 +31,7 @@ import {
applyManualOffsetNudgeDraft,
createManualOffsetDragMember,
endManualOffsetDragMembers,
manualOffsetMoveRevert,
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
Expand All @@ -47,7 +48,7 @@ interface NudgeSession {
/** Accumulated delta of the burst, in composition px. */
accum: { x: number; y: number };
timer: ReturnType<typeof setTimeout> | null;
endPendingEdit: (saved?: Promise<unknown>) => void;
edit: ReturnType<typeof beginStudioPendingEdit>;
}

export interface UseDomEditNudgeParams {
Expand Down Expand Up @@ -161,11 +162,16 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
next: applyManualOffsetNudgeCommit(member, session.accum),
plainTranslate: member.plainTranslate,
}));
const commit = session.isGroup
? p.onGroupPathOffsetCommitRef.current(updates)
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
plainTranslate: updates[0].plainTranslate,
});
const commit = session.edit.adopt(() =>
session.isGroup
? p.onGroupPathOffsetCommitRef.current(updates)
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
plainTranslate: updates[0].plainTranslate,
}),
);
if (session.edit.reverted())
for (const member of session.members)
restoreStudioPathOffset(member.element, member.initialPathOffset);
saved = Promise.resolve(commit)
.catch(() => {
for (const member of session.members) {
Expand All @@ -177,7 +183,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
.finally(() => endManualOffsetDragMembers(session.members));
return saved;
} finally {
session.endPendingEdit(saved);
session.edit.settle(saved);
}
};
const commitSessionRef = useRef(commitSession);
Expand All @@ -190,7 +196,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
if (session.timer) clearTimeout(session.timer);
restoreManualOffsetDragMembers(session.members);
endManualOffsetDragMembers(session.members);
session.endPendingEdit();
session.edit.settle();
}, [params.allowCanvasMovement]);

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

const handleKeyDown = (event: KeyboardEvent) => {
Expand Down
23 changes: 17 additions & 6 deletions packages/studio/src/components/editor/useDomEditOverlayGestures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
applyManualOffsetDragDraft,
endManualOffsetDragMembers,
restoreManualOffsetDragMembers,
manualOffsetMoveRevert,
} from "./manualOffsetDrag";
import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
import {
Expand Down Expand Up @@ -57,6 +58,7 @@ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDeb
import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
import { createGroupDragMover } from "./groupDragMove";
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
import { beginStudioPendingEdit } from "../../utils/studioPendingEdits";

function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) {
return (
Expand Down Expand Up @@ -344,7 +346,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
),
at: readDragPositions(groupG.members),
});
void Promise.resolve(opts.onGroupPathOffsetCommitRef.current(updates))
const groupEdit = beginStudioPendingEdit(manualOffsetMoveRevert(groupG.members));
const groupSaved = Promise.resolve(
groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
)
.catch(() => {
for (const member of groupG.members) {
if (
Expand All @@ -363,6 +368,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
// so this is where a snap-back would show.
logDragSettle("settle", groupG.members);
});
groupEdit.settle(groupSaved);
return;
}

Expand Down Expand Up @@ -469,11 +475,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
box.style.left = `${nextBoxLeft}px`;
box.style.top = `${nextBoxTop}px`;
}
void Promise.resolve(
opts.onPathOffsetCommitRef.current(sel, finalOffset, {
altKey: e.altKey,
plainTranslate: g.pathOffsetMember.plainTranslate,
}),
const member = g.pathOffsetMember;
const edit = beginStudioPendingEdit(manualOffsetMoveRevert([member]));
const saved = Promise.resolve(
edit.adopt(() =>
opts.onPathOffsetCommitRef.current(sel, finalOffset, {
altKey: e.altKey,
plainTranslate: member.plainTranslate,
}),
),
)
.catch(() => {
if (
Expand All @@ -485,6 +495,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
.finally(() => {
if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
});
edit.settle(saved);
} else {
opts.suppressNextBoxClickRef.current = true;
const finalSize = readStudioBoxSize(sel.element);
Expand Down
41 changes: 41 additions & 0 deletions packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
const s = await studio();
await s.edit();
resetNudgeKeys();
let finish!: () => void;
const save = vi.fn(async () => {
await new Promise<void>((resolve) => (finish = resolve));
writeFileSync(s.path, NUDGED);
await s.history().recordEdit({
label: "Move layer",
Expand All @@ -218,12 +220,51 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
});

const nudge = () => s.element("box").style.getPropertyValue("translate");
expect(nudge()).not.toBe("");

const undone = s.actions().undo();
expect(s.box()).toBe("50px");
expect(nudge()).toBe("");
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
expect(nudge()).toBe("");
finish();
await act(() => undone);
expect(save).toHaveBeenCalledTimes(1);
expect(s.file()).toBe(AFTER);
expect(s.box()).toBe("50px");
expect(nudge()).toBe("");
});

it("an undo pressed while a nudge's save is queued shows the nudge undone in the key's own task", async () => {
const s = await studio();
await s.edit();
resetNudgeKeys();
let finish!: () => void;
const save = vi.fn(async () => {
await new Promise<void>((resolve) => (finish = resolve));
writeFileSync(s.path, NUDGED);
await s.history().recordEdit({
label: "Move layer",
files: { "index.html": { before: AFTER, after: NUDGED } },
});
});
s.mount(createElement(Nudge, { target: s.element("box"), save }));
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
});
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
const nudge = () => s.element("box").style.getPropertyValue("translate");
expect(nudge()).not.toBe("");

const undone = s.actions().undo();
expect(nudge()).toBe("");
expect(s.box()).toBe("50px");
finish();
await act(() => undone);
expect(s.file()).toBe(AFTER);
expect(nudge()).toBe("");
expect(s.box()).toBe("50px");
});

it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
Expand Down
17 changes: 13 additions & 4 deletions packages/studio/src/hooks/useEditHistoryActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
import type { RestoreFiles } from "../utils/gsapUndoRestore";
import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits";

interface HistoryResult {
ok: boolean;
Expand Down Expand Up @@ -74,7 +75,10 @@ export function useEditHistoryActions({
const noun = direction === "undo" ? "Undo" : "Redo";
// Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
const predicted = editHistory.predict?.(direction) ?? null;
const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
const pendingEditShown =
!predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null;
const putBack = predictedShown ?? pendingEditShown;
let result: HistoryResult = { ok: false, reason: "failed" };
let serverSteppedShown = false;
try {
Expand All @@ -83,8 +87,10 @@ export function useEditHistoryActions({
readFile: readHistoryFile,
serialize: serializeHistoryFiles,
});
serverSteppedShown =
Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id;
const stepped = Boolean(result.ok && result.label);
serverSteppedShown = predictedShown
? stepped && result.undoes === predicted?.id
: stepped && Boolean(pendingEditShown);
} finally {
if (putBack && !serverSteppedShown) putBack();
}
Expand All @@ -100,7 +106,10 @@ export function useEditHistoryActions({
return;
}
if (result.ok && result.label) {
const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files;
const files =
serverSteppedShown && predictedShown
? fromShown(result.files, predicted!.files)
: result.files;
const restore = { paths: result.paths, files };
onAfterUndoRedo?.(restore);
if (activeCompPath && result.paths?.includes(activeCompPath)) {
Expand Down
15 changes: 9 additions & 6 deletions packages/studio/src/hooks/usePersistentEditHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,20 +243,23 @@ it("a step that cannot reach the server says so", async () => {
});
});

it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
it("predicts a step from what this tab wrote as soon as its save ends, and not while a claim or step may have moved the history", async () => {
const { hook, save, readFile } = await studio();
const real = globalThis.fetch;
const slowView = (url: string, init?: RequestInit) =>
url.endsWith("/history")
? new Promise((r) => setTimeout(r, 50)).then(() => real(url, init))
: real(url, init);
vi.stubGlobal("fetch", slowView);
save("B");
const claim = hook().recordEdit({
label: "Moved Title",
files: { "index.html": { before: "A", after: "B" } },
});
expect(hook().predict("undo")).toBeNull();
await act(() => claim);
const predicted = await vi.waitFor(() => {
const next = hook().predict("undo");
expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
return next!;
});
const predicted = hook().predict("undo")!;
expect(predicted?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });

hook().noteOutsideChange();
expect(hook().predict("undo")).toBeNull();
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/hooks/usePersistentEditHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
const claimed = claimHeld(reply, label);
if (claimed) own.remember(claimed, files);
heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
void refresh();
await refresh();
},
[projectId, refresh, own],
);
Expand Down
Loading
Loading