Skip to content
Merged
8 changes: 5 additions & 3 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { trackedStudioEdit } from "./utils/studioPendingEdits";
import { useClipboard } from "./hooks/useClipboard";
import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
import { useCaptionDetection } from "./hooks/useCaptionDetection";
Expand Down Expand Up @@ -256,7 +257,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
showToast,
syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
showHistoryRestoreNow: previewPersistence.showHistoryRestoreNow,
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
settlePendingEdits: previewPersistence.settlePendingEdits,
handleCopy,
handlePaste,
handleCut,
Expand Down Expand Up @@ -315,9 +316,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
readOnlyPreview,
});
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
const { handleDomZIndexReorderCommit: zCommit, handleDomEditElementDelete: del } = domEditSession;
handleDomZIndexReorderCommitRef.current = trackedStudioEdit(zCommit);
clearDomSelectionRef.current = domEditSession.clearDomSelection;
handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
handleDomEditElementDeleteRef.current = trackedStudioEdit(del);
resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession);
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/SnapToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function AppHotkeyHarness() {
writeProjectFile: vi.fn(async () => undefined),
showToast: vi.fn(),
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
waitForPendingDomEditSaves: vi.fn(async () => undefined),
settlePendingEdits: vi.fn(async () => undefined),
handleCopy: vi.fn(() => false),
handlePaste: vi.fn(async () => undefined),
handleCut: vi.fn(async () => false),
Expand Down
91 changes: 89 additions & 2 deletions packages/studio/src/components/editor/useDomEditNudge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
import { __resetForTests } from "../../utils/canvasNudgeGate";
import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits";
import type { DomEditSelection } from "./domEditing";
import type { OverlayRect } from "./domEditOverlayGeometry";

Expand All @@ -27,6 +28,7 @@ const REST_RECT: OverlayRect = {
// Stable across renders on purpose: the test targets the `selection` identity
// key specifically, so `groupSelections` must not itself be a source of churn.
const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
let flushNudge = () => {};

function Harness({
selection,
Expand All @@ -35,7 +37,7 @@ function Harness({
selection: DomEditSelection | null;
onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
}) {
useDomEditNudge({
flushNudge = useDomEditNudge({
selection,
groupSelections: EMPTY_GROUP_SELECTIONS,
allowCanvasMovement: true,
Expand All @@ -49,7 +51,7 @@ function Harness({
onBlockedMoveRef: makeRef(() => {}),
onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
onGroupPathOffsetCommitRef: makeRef(async () => {}),
});
}).flushNudge;
return null;
}

Expand Down Expand Up @@ -291,3 +293,88 @@ describe("useDomEditNudge carries the route its press chose", () => {
element.remove();
});
});

describe("useDomEditNudge — undo right after a burst", () => {
it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
__resetForTests();
vi.useFakeTimers();
const root = createRoot(document.body.appendChild(document.createElement("div")));
const element = document.body.appendChild(document.createElement("div"));
element.id = "dot-undo";
let saved!: () => void;
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
act(() => {
root.render(
React.createElement(Harness, {
selection: makeSelection("Dot", element),
onPathOffsetCommit: commit,
}),
);
});
act(() => dispatchArrowRight());

let drained = false;
const drain = flushStudioPendingEdits().then(() => (drained = true));
expect(commit).toHaveBeenCalledTimes(1);
vi.useRealTimers();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(drained).toBe(false);
saved();
await drain;
expect(drained).toBe(true);
act(() => root.unmount());
});

it("counts the burst as a pending edit from its first key until its save lands", async () => {
__resetForTests();
vi.useFakeTimers();
const root = createRoot(document.body.appendChild(document.createElement("div")));
const element = document.body.appendChild(document.createElement("div"));
element.id = "dot-pending";
let saved!: () => void;
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
act(() => {
root.render(
React.createElement(Harness, {
selection: makeSelection("Dot", element),
onPathOffsetCommit: commit,
}),
);
});
expect(hasStudioPendingEdits()).toBe(false);
act(() => dispatchArrowRight());
expect(hasStudioPendingEdits()).toBe(true);
act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
expect(commit).toHaveBeenCalledTimes(1);
expect(hasStudioPendingEdits()).toBe(true);
vi.useRealTimers();
saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
act(() => root.unmount());
});
});

describe("useDomEditNudge — a commit that throws", () => {
it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
__resetForTests();
const root = createRoot(document.body.appendChild(document.createElement("div")));
const element = document.body.appendChild(document.createElement("div"));
element.id = "dot-throws";
const commit = vi.fn(() => {
throw new Error("The commit threw.");
});
act(() => {
root.render(
React.createElement(Harness, {
selection: makeSelection("Dot", element),
onPathOffsetCommit: commit,
}),
);
});
act(() => dispatchArrowRight());
expect(hasStudioPendingEdits()).toBe(true);
expect(() => flushNudge()).toThrow("The commit threw.");
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
act(() => root.unmount());
});
});
62 changes: 40 additions & 22 deletions packages/studio/src/components/editor/useDomEditNudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { useCallback, useEffect, useRef, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { ownsPlainKeys } from "../../utils/typingTarget";
import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
import {
addStudioPendingEditFlushListener,
trackStudioPendingEdit,
} from "../../utils/studioPendingEdits";
import type { DomEditSelection } from "./domEditing";
import {
type GroupOverlayItem,
Expand Down Expand Up @@ -43,6 +47,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;
}

export interface UseDomEditNudgeParams {
Expand Down Expand Up @@ -143,31 +148,37 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
// Commit the pending burst: one source write per burst = one undo entry.
// Mirrors the drag's onPointerUp — same commit callbacks, same failure
// restore, same member teardown.
const commitSession = () => {
const commitSession = (): Promise<unknown> | undefined => {
const session = sessionRef.current;
if (!session) return;
if (!session) return undefined;
sessionRef.current = null;
if (session.timer) clearTimeout(session.timer);
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
selection: member.selection,
next: applyManualOffsetNudgeCommit(member, session.accum),
plainTranslate: member.plainTranslate,
}));
const p = paramsRef.current;
const commit = session.isGroup
? p.onGroupPathOffsetCommitRef.current(updates)
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
plainTranslate: updates[0].plainTranslate,
});
void Promise.resolve(commit)
.catch(() => {
for (const member of session.members) {
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
restoreStudioPathOffset(member.element, member.initialPathOffset);
let saved: Promise<unknown> | undefined;
try {
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
selection: member.selection,
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,
});
saved = Promise.resolve(commit)
.catch(() => {
for (const member of session.members) {
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
restoreStudioPathOffset(member.element, member.initialPathOffset);
}
}
}
})
.finally(() => endManualOffsetDragMembers(session.members));
})
.finally(() => endManualOffsetDragMembers(session.members));
return saved;
} finally {
session.endPendingEdit(saved);
}
};
const commitSessionRef = useRef(commitSession);
commitSessionRef.current = commitSession;
Expand All @@ -179,6 +190,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
if (session.timer) clearTimeout(session.timer);
restoreManualOffsetDragMembers(session.members);
endManualOffsetDragMembers(session.members);
session.endPendingEdit();
}, [params.allowCanvasMovement]);

// Build drag members for the current target set — the same member snapshot a
Expand All @@ -205,7 +217,9 @@ 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?.();
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null };
let endPendingEdit: NudgeSession["endPendingEdit"] = () => {};
trackStudioPendingEdit(new Promise<unknown>((resolve) => (endPendingEdit = resolve)));
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit };
};

const handleKeyDown = (event: KeyboardEvent) => {
Expand All @@ -231,8 +245,12 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
// Capture, like the other app-level key handlers, so a focused panel
// can't swallow the nudge before it reaches us.
window.addEventListener("keydown", listener, true);
const stopCommitOnUndoDrain = addStudioPendingEditFlushListener(() =>
commitSessionRef.current(),
);
return () => {
window.removeEventListener("keydown", listener, true);
stopCommitOnUndoDrain();
commitSessionRef.current();
};
});
Expand All @@ -245,7 +263,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
const selectionKey = selectionIdentityKey(params.selection);
const groupSelectionsKey = groupSelectionsIdentityKey(params.groupSelections);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => () => commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
useEffect(() => () => void commitSessionRef.current(), [selectionKey, groupSelectionsKey]);

// Claim the arrow keys from the playback frame-step while the selection is
// nudgeable (see canvasNudgeGate — listener order is mount-dependent, so
Expand Down
62 changes: 62 additions & 0 deletions packages/studio/src/contexts/DomEditContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
import { DomEditProvider, useDomEditActionsContext } from "./DomEditContext";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const cleanup: Array<() => void> = [];
afterEach(() => cleanup.splice(0).forEach((step) => step()));

/** A resize whose save is still running, committed through the actions the canvas and panels use. */
function commitResize(save: Promise<void>) {
const handleDomBoxSizeCommit = vi.fn(() => save);
const value = { handleDomBoxSizeCommit } as unknown as Parameters<
typeof DomEditProvider
>[0]["value"];
let actions!: ReturnType<typeof useDomEditActionsContext>;
function Canvas() {
actions = useDomEditActionsContext();
return null;
}
const root = createRoot(document.createElement("div"));
act(() =>
root.render(
<DomEditProvider value={value}>
<Canvas />
</DomEditProvider>,
),
);
cleanup.push(() => act(() => root.unmount()));
const committed = actions.handleDomBoxSizeCommit(
{} as never,
{ width: 300, height: 200 },
undefined,
);
return { committed, handleDomBoxSizeCommit };
}

it("undo waits for an edit committed before its save has written anything", async () => {
let finish!: () => void;
const { committed } = commitResize(new Promise<void>((resolve) => (finish = resolve)));
let drained = false;
const drain = flushStudioPendingEdits().then(() => (drained = true));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(drained).toBe(false);
finish();
await drain;
await committed;
expect(drained).toBe(true);
});

it("an edit that fails is waited out and reported to the drain, like every tracked edit", async () => {
let fail!: (error: Error) => void;
const { committed } = commitResize(new Promise<void>((_, reject) => (fail = reject)));
const drain = flushStudioPendingEdits();
const failure = new Error("blocked");
fail(failure);
await expect(committed).rejects.toThrow("blocked");
await expect(drain).resolves.toEqual({ status: "failed", error: failure });
});
Loading
Loading