Skip to content

Commit c8e8305

Browse files
committed
fix(studio): undo keeps a move undone when its history claim landed before the key
1 parent 0d19b3e commit c8e8305

9 files changed

Lines changed: 105 additions & 28 deletions

‎packages/studio/src/hooks/timelineEditingHelpers.test.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
patchIframeDomTiming,
1111
persistElementAttribute,
1212
persistTimelineBatchEdit,
13-
syncCompositionDurationToContent,
1413
type PersistTimelineBatchChange,
1514
} from "./timelineEditingHelpers";
1615
import type { TimelineElement } from "../player/store/playerStore";
@@ -318,19 +317,17 @@ describe("persistTimelineBatchEdit", () => {
318317
vi.unstubAllGlobals();
319318
});
320319

321-
it("finishes each file once, not once per member, and lands where per-member sync would", async () => {
320+
it("syncs each file's root duration once, landing where per-member sync would", async () => {
322321
const source = `<div id="root" data-composition-id="main" data-duration="4"><video id="a" class="clip" data-start="1" data-duration="1"></video><video id="b" class="clip" data-start="2" data-duration="1"></video><video id="c" class="clip" data-start="3" data-duration="1"></video></div>`;
323322
const members = ["a", "b", "c"].map((id, i) => ({
324323
element: el({ id, tag: "video", domId: id, start: i + 1, duration: 1 }),
325324
buildPatches: (original: string, target: Parameters<typeof applyTimelineMoveAttributes>[1]) =>
326325
applyTimelineMoveAttributes(original, target, i + 6, 1),
327326
}));
328-
const finishFile = vi.fn(syncCompositionDurationToContent);
329327
stubReadFileContent(source);
330328
const writes: Array<[string, string]> = [];
331-
await persistTimelineBatchEdit({ ...batchInput(members, writes), finishFile });
329+
await persistTimelineBatchEdit(batchInput(members, writes));
332330

333-
expect(finishFile).toHaveBeenCalledTimes(1);
334331
const perMember = members.reduce(
335332
(current, { element }, i) =>
336333
buildTimelineMoveTimingPatch(current, { id: element.id }, i + 6, 1),

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,6 @@ export interface PersistTimelineBatchEditInput {
406406
coalesceKey?: string;
407407
/** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
408408
coalesceMs?: number;
409-
/** Applied once to each patched file, after every change to it. */
410-
finishFile?: (patched: string) => string;
411409
}
412410

413411
export async function persistTimelineBatchEdit(
@@ -424,7 +422,7 @@ export async function persistTimelineBatchEdit(
424422
targetPath,
425423
changesByPath.get(targetPath)!,
426424
);
427-
const next = input.finishFile ? input.finishFile(patched) : patched;
425+
const next = syncCompositionDurationToContent(patched);
428426
if (next !== original) input.pendingTimelineEditPathRef.current.add(targetPath);
429427
return next;
430428
};

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

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,19 @@ import { createRoot, type Root } from "react-dom/client";
55
import { afterEach, describe, expect, it, vi } from "vitest";
66
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
77
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
8-
import { beginStudioPendingEdit, trackStudioPendingEdit } from "../utils/studioPendingEdits";
8+
import {
9+
beginStudioPendingEdit,
10+
setStudioPendingEditClaimClock,
11+
trackStudioPendingEdit,
12+
} from "../utils/studioPendingEdits";
913

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

1216
let root: Root | null = null;
13-
afterEach(() => act(() => root?.unmount()));
17+
afterEach(() => {
18+
act(() => root?.unmount());
19+
setStudioPendingEditClaimClock(null);
20+
});
1421

1522
type RestoreFiles = Record<string, { previous: string; restored: string }>;
1623
type Prediction = { id: string; files: RestoreFiles };
@@ -126,23 +133,28 @@ describe("useEditHistoryActions", () => {
126133
expect(reapply).not.toHaveBeenCalled();
127134
});
128135

136+
// Claim counts: when the edit began, at the key, and once its save landed.
129137
it.each([
130-
["its claim counted, the server undo is the shown revert", 8, 0],
131-
["its claim never counted, the move is shown again", 7, 1],
132-
])("an edit that lands while undo waits: %s", async (_, claimsAfter, reapplied) => {
138+
["its claim counted while undo waited, the server undo is the shown revert", 7, 7, 8, 0],
139+
["its claim counted before the key, the server undo is the shown revert", 7, 8, 8, 0],
140+
["its claim never counted, the move is shown again", 7, 7, 7, 1],
141+
])("an edit that lands while undo waits: %s", async (_, atBegin, atKey, atLand, reapplied) => {
142+
let claimCount = atBegin;
143+
setStudioPendingEditClaimClock(() => claimCount);
133144
const reapply = vi.fn();
134145
const saving = beginStudioPendingEdit(() => reapply);
135-
let claimCount = 7;
146+
claimCount = atKey;
136147
const { deps, actions } = mount(
137-
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e2" },
148+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e8" },
138149
PREDICTED,
139150
() => claimCount,
140151
);
141152
const undone = actions.undo();
142153
saving.settle(saving.adopt(() => Promise.resolve()));
143-
claimCount = claimsAfter;
154+
claimCount = atLand;
144155
await act(() => undone);
145156
expect(deps.editHistory.undo).toHaveBeenCalledTimes(1);
157+
expect(deps.editHistory.undo.mock.calls[0]![0].claimedAfter).toBe(atBegin);
146158
expect(deps.showHistoryRestoreNow).not.toHaveBeenCalled();
147159
expect(reapply).toHaveBeenCalledTimes(reapplied);
148160
});

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ export function useEditHistoryActions({
8484
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
8585
const putBack = predictedShown ?? pendingEditShown?.showAgain;
8686
const claimedAfter =
87-
direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : undefined;
87+
pendingEditShown?.claimsAtBegin ??
88+
(direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : undefined);
8889
let result: HistoryResult = { ok: false, reason: "failed" };
8990
let serverSteppedShown = false;
9091
let revertIsTheUndo = false;
@@ -98,11 +99,12 @@ export function useEditHistoryActions({
9899
claimedAfter,
99100
});
100101
const stepped = Boolean(result.ok && result.label);
101-
const claimedSinceKey =
102-
claimedAfter !== undefined && (editHistory.claims?.() ?? claimedAfter) > claimedAfter;
102+
const editClaimed =
103+
pendingEditShown !== null &&
104+
(editHistory.claims?.() ?? 0) > pendingEditShown.claimsAtBegin;
103105
serverSteppedShown = predictedShown
104106
? stepped && result.undoes === predicted?.id
105-
: stepped && Boolean(pendingEditShown) && claimedSinceKey;
107+
: stepped && editClaimed;
106108
} finally {
107109
if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
108110
}

‎packages/studio/src/hooks/usePersistentEditHistory.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
type StudioApiAdapter,
1515
} from "@hyperframes/studio-server";
1616
import { consumeStudioWriteToken } from "../utils/studioFileVersion";
17+
import {
18+
beginStudioPendingEdit,
19+
paintBackNewestStudioPendingEdit,
20+
} from "../utils/studioPendingEdits";
1721
import { usePersistentEditHistory } from "./usePersistentEditHistory";
1822

1923
const cleanup: Array<() => unknown> = [];
@@ -88,6 +92,22 @@ it("an edit Studio saved is undone and redone by the project's history, with the
8892
expect(file()).toBe("B");
8993
});
9094

95+
it("gives an edit that begins the history's claim count, so undo can tell its claims from older ones", async () => {
96+
const { hook, save } = await studio();
97+
save("B");
98+
await act(() =>
99+
hook().recordEdit({
100+
label: "Moved Title",
101+
files: { "index.html": { before: "A", after: "B" } },
102+
}),
103+
);
104+
const saving = beginStudioPendingEdit(() => () => {});
105+
106+
expect(paintBackNewestStudioPendingEdit()?.claimsAtBegin).toBe(hook().claims());
107+
expect(hook().claims()).toBe(1);
108+
saving.settle();
109+
});
110+
91111
it("undoes the edit claimed since the key, not a later edit the server took in first", async () => {
92112
const { dir, hook, file, save, readFile } = await studio();
93113
const atKey = hook().claims();

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server"
33
import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
44
import type { RestoreFiles } from "../utils/gsapUndoRestore";
55
import { studioApiFetch } from "../utils/studioApiFetch";
6+
import { setStudioPendingEditClaimClock } from "../utils/studioPendingEdits";
67
import type { RecordEditInput } from "../utils/studioFileHistory";
78

89
interface ApplyCallbacks {
@@ -228,6 +229,11 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
228229
void refresh().finally(() => setLoaded(true));
229230
}, [refresh, own]);
230231

232+
useEffect(() => {
233+
setStudioPendingEditClaimClock(own.claimCount);
234+
return () => setStudioPendingEditClaimClock(null);
235+
}, [own]);
236+
231237
const recordEdit = useCallback(
232238
async ({ label, coalesceKey, coalesceMs, files, created = [] }: RecordEditInput) => {
233239
if (!projectId) return;

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
import {
1313
applyTimelineMoveAttributes,
1414
applyTimelineResizeAttributes,
15-
syncCompositionDurationToContent,
1615
extendRootDurationIfNeeded,
1716
formatTimelineAttributeNumber,
1817
formatTimelineMediaOffset,
@@ -176,7 +175,6 @@ export function useTimelineGroupEditing({
176175
pendingTimelineEditPathRef,
177176
coalesceKey,
178177
coalesceMs,
179-
finishFile: syncCompositionDurationToContent,
180178
});
181179
forceReloadSdkSession?.();
182180
},

‎packages/studio/src/player/components/TimelineLanes.test.tsx‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import { defaultTimelineTheme } from "./timelineTheme";
1010
import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
1111
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
1212
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
13-
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
13+
import {
14+
usePlayerStore,
15+
type KeyframeCacheEntry,
16+
type TimelineElement,
17+
} from "../store/playerStore";
1418
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
1519
import type { TimelineEditCallbacks } from "./timelineCallbacks";
1620
import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
@@ -73,6 +77,7 @@ function positionTween(id: string): GsapAnimation {
7377

7478
interface RenderLanesOptions {
7579
elements?: TimelineElement[];
80+
keyframeCache?: Map<string, KeyframeCacheEntry>;
7681
animations?: Map<string, GsapAnimation[]>;
7782
expandedClipIds?: string[];
7883
selectedElementIds?: Set<string>;
@@ -172,6 +177,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
172177
getPreviewElement={(el) => el}
173178
getTrackStyle={getTrackStyle}
174179
gsapAnimations={gsapAnimations}
180+
keyframeCache={next.keyframeCache}
175181
selectedKeyframes={new Set()}
176182
currentTime={0}
177183
onContextMenuLane={next.onContextMenuLane}
@@ -399,18 +405,37 @@ describe("TimelineLanes disclosure target", () => {
399405
draggedPreviewStart: 0.5,
400406
selectedKeys: selectedElementIds,
401407
};
402-
const view = renderLanes({ elements, selectedElementIds });
408+
const keyframeCache = new Map<string, KeyframeCacheEntry>([
409+
[
410+
"clip-a",
411+
{
412+
format: "percentage",
413+
keyframes: [
414+
{ percentage: 0, properties: { x: 0 } },
415+
{ percentage: 100, properties: { x: 50 } },
416+
],
417+
},
418+
],
419+
]);
420+
const view = renderLanes({ elements, selectedElementIds, keyframeCache });
403421
const clipA = view.host.querySelector<HTMLElement>('[data-el-id="clip-a"]');
422+
const diamonds = () =>
423+
view.host
424+
.querySelector('[data-el-id="clip-a"] ~ div button[aria-label*="keyframe at"]')
425+
?.closest<HTMLElement>("div.pointer-events-none");
404426
expect(clipA).not.toBeNull();
405427
expect(clipA?.parentElement).toBe(
406428
view.host.querySelector('[data-el-id="clip-b"]')?.parentElement,
407429
);
408430

409-
view.rerender({ elements, selectedElementIds, multiDragPreview: dragging });
431+
expect(diamonds()).not.toBeNull();
432+
view.rerender({ elements, selectedElementIds, keyframeCache, multiDragPreview: dragging });
410433
expect(view.host.querySelector('[data-el-id="clip-a"]')).toBe(clipA);
411434
expect(clipA?.style.transform).toMatch(/^translateX\([1-9]/);
435+
// Its keyframe diamonds ride with it.
436+
expect(diamonds()?.style.transform).toBe(clipA?.style.transform);
412437

413-
view.rerender({ elements, selectedElementIds });
438+
view.rerender({ elements, selectedElementIds, keyframeCache });
414439
expect(view.host.querySelector('[data-el-id="clip-a"]')).toBe(clipA);
415440
expect(clipA?.style.transform).toBe("");
416441
act(() => view.root.unmount());
@@ -437,9 +462,11 @@ describe("TimelineLanes disclosure target", () => {
437462
});
438463

439464
const before = ariaControlsTarget(view.host);
440-
const beforeLane = before?.querySelector("[data-timeline-property-lane]");
465+
const beforeLane = before?.querySelector<HTMLElement>("[data-timeline-property-lane]");
441466
expect(before).not.toBeNull();
442467
expect(beforeLane).not.toBeNull();
468+
const startOffset = beforeLane?.style.transform;
469+
expect(startOffset).toMatch(/^translateX\([1-9]/);
443470

444471
view.rerender({
445472
elements,
@@ -452,6 +479,9 @@ describe("TimelineLanes disclosure target", () => {
452479
// Node identity, not just presence: a remount replaces these nodes.
453480
expect(ariaControlsTarget(view.host)).toBe(before);
454481
expect(before?.querySelector("[data-timeline-property-lane]")).toBe(beforeLane);
482+
// The lanes ride with the formation, not just the clip.
483+
expect(beforeLane?.style.transform).toMatch(/^translateX\([1-9]/);
484+
expect(beforeLane?.style.transform).not.toBe(startOffset);
455485
act(() => view.root.unmount());
456486
});
457487
});

‎packages/studio/src/utils/studioPendingEdits.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface PendingEdit {
1616
landed: () => Promise<boolean>;
1717
redraws: Array<() => void>;
1818
showAgain: (() => void) | null;
19+
claimsAtBegin: number;
1920
}
2021

2122
export interface StudioEditInFlight {
@@ -27,6 +28,11 @@ export interface StudioEditInFlight {
2728
}
2829

2930
const pendingEdits = new Map<Promise<unknown>, PendingEdit>();
31+
let historyClaims: () => number = () => 0;
32+
33+
export function setStudioPendingEditClaimClock(read: (() => number) | null): void {
34+
historyClaims = read ?? (() => 0);
35+
}
3036
const NOT_SAVED = () => Promise.resolve(false);
3137
let adopting: StudioEditInFlight | null = null;
3238

@@ -87,7 +93,13 @@ export function trackStudioPendingEdit(
8793
if (!result) return undefined;
8894
const promise = Promise.resolve(result);
8995
if (adopting) return promise;
90-
pendingEdits.set(promise, { revert: null, landed: NOT_SAVED, redraws: [], showAgain: null });
96+
pendingEdits.set(promise, {
97+
revert: null,
98+
landed: NOT_SAVED,
99+
redraws: [],
100+
showAgain: null,
101+
claimsAtBegin: historyClaims(),
102+
});
91103
promise.then(
92104
() => pendingEdits.delete(promise),
93105
() => pendingEdits.delete(promise),
@@ -162,6 +174,7 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
162174
export function paintBackNewestStudioPendingEdit(): {
163175
showAgain: () => void;
164176
landed: () => Promise<boolean>;
177+
claimsAtBegin: number;
165178
} | null {
166179
const newest = [...pendingEdits.values()].at(-1);
167180
const revert = newest?.revert;
@@ -174,6 +187,7 @@ export function paintBackNewestStudioPendingEdit(): {
174187
for (const redraw of newest.redraws.splice(0)) redraw();
175188
},
176189
landed: newest.landed,
190+
claimsAtBegin: newest.claimsAtBegin,
177191
};
178192
}
179193

0 commit comments

Comments
 (0)